Accueil / Articles

Comment l’injection de dépendances d’Angular résout les services avec inject()

Comprendre comment Angular DI fournit des services via inject(), providers et injecteurs hiérarchiques, ainsi que la manière de définir le champ d’application des instances et d’éviter les erreurs liées à l’absence de provider.

Éditeur : REACTAPP.TOP12 min de lecture2666 mots

Sources: Angular 09: Dependency Injection in Angular

Lorsque vous commencez à déplacer la logique hors des composants Angular vers des services, une question pratique se pose : lorsque un composant écrit inject(UserService), d’où provient réellement cet objet, qui l’a créé, et quelle instance le composant reçoit-il ? La réponse réside dans le système d’injection de dépendances (DI) d’Angular. Ce guide explique ce qu’est le DI, comment fonctionnent inject() et les fournisseurs, comment les injecteurs hiérarchiques déterminent l’instance à utiliser, ainsi que comment éviter la plus courante erreur des débutants, afin que vous puissiez partager ou encadrer des services dans vos propres applications avec confiance.

Voici la ligne qui suscite généralement cette question :

userService = inject(UserService);

Rien dans le composant ne crée UserService, pourtant le champ contient un objet prêt à l’emploi. C’est le système d’injection de dépendances d’Angular qui l’a fourni.

Pourquoi les composants ne devraient pas créer leurs propres services

L’alternative évidente est de construire le service soi-même à l’intérieur du composant :

userService = new UserService();

Cela fonctionne, mais cela lie le composant à une classe concrète et à une seule manière de la construire. Le composant décide alors comment le service est créé, quels éléments il nécessite pour exister, et s’il faut en créer une nouvelle copie à chaque fois. Si le service a par la suite besoin de ses propres dépendances, tous les endroits qui appellent new doivent également être modifiés.

Le DI inverse cette responsabilité. Le composant se contente de déclarer qu’il a besoin de UserService. Angular trouve un fournisseur correspondant, crée l’instance ou réutilise une instance existante, puis la lui transmet :

Component
    │
    │ "I need UserService"
    ↓
Angular DI System
    │
    ↓
UserService

Les deux mots du nom décrivent exactement cela. Une dépendance est tout ce dont une classe a besoin pour fonctionner. Injection signifie que le framework fournit cette dépendance à la classe au lieu que celle-ci la récupère ou la crée elle-même.

Première injection, du début à la fin

Commencez par un petit service. Le décorateur @Injectable indique que la classe peut être créée par le système de DI d’Angular, et providedIn: 'root' la enregistre dans toute l’application :

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class UserService {
  getUserName() {
    return 'Rahul';
  }
}

Le composant demande le service à l’aide de inject(), importé depuis @angular/core en même temps que Component:

import { Component, inject } from '@angular/core';
import { UserService } from './user.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  userService = inject(UserService);
}

Lorsque Angular construit App, il évalue cette expression :

inject(UserService)

Angular interprète cela comme « cette classe a besoin d’un UserService », résout la dépendance et attribue le résultat au champ. Comme le champ est public, le template peut appeler directement le service :

<h1>{{ userService.getUserName() }}</h1>

Remarquez ce qui manque dans le composant. Il n’y a aucune appel de construction nulle part :

new UserService()

Créer et transmettre l’instance relève entièrement du rôle d’Angular.

Travailler avec inject()

inject() est l’API moderne permettant de demander une dépendance au système DI d’Angular. Vous lui passez un token, le plus souvent la classe de service elle-même, et vous recevez l’instance :

userService = inject(UserService);

La fonction provient du package de base d’Angular :

import { Component, inject } from '@angular/core';

Avec le champ attribué dans le corps de la classe :

userService = inject(UserService);

chaque méthode de la classe peut accéder au service via this :

this.userService

Par exemple, une méthode qui lit le nom et l’enregistre :

loadUser() {
  const name = this.userService.getUserName();
  console.log(name);
}

Une règle à retenir : inject() ne fonctionne qu’à l’intérieur d’un contexte d’injection. En pratique, cela signifie les initialisateurs de champs, le constructeur, ainsi que quelques endroits gérés par le framework tels que les fonctions de fabrication et les gardes fonctionnels. L’appeler plus tard, par exemple à l’intérieur d’un gestionnaire de clic ou d’un callback setTimeout, provoque une erreur. Obtenez la dépendance dans un champ, puis utilisez ce champ là où vous en avez besoin.

inject() comparé à l’injection par constructeur

Les tutoriels plus anciens et de nombreux bases de code existantes demandent les dépendances via des paramètres de constructeur :

constructor(private userService: UserService) {}

Ce style basé sur le constructeur est toujours pleinement pris en charge. L’équivalent basé sur les champs se présente comme ceci :

userService = inject(UserService);

Les deux méthodes communiquent avec le même système DI et produisent le même résultat. Pour du nouveau code, inject() a tendance à être plus court, s’intègre naturellement avec les composants indépendants et d’autres API modernes d’Angular, et évite de longues listes de paramètres de constructeur, ce qui devient problématique lors de l’utilisation de l’héritage. Une classe typique ressemble alors à ceci :

export class App {
  userService = inject(UserService);
}

Cependant, la syntaxe est la partie la moins importante. Ce qui compte, c’est le principe : le composant reçoit sa dépendance d’Angular plutôt que de la créer lui-même.

Fournisseurs : indiquer à Angular comment fournir une dépendance

Demander une dépendance ne fonctionne que si Angular sait comment la générer. Cette information se trouve dans un fournisseur. La manière la plus courante de le déclarer est à l’aide du décorateur @Injectable :

@Injectable({
  providedIn: 'root'
})
export class UserService {
}

L’option clé se trouve dans cette seule ligne :

providedIn: 'root'

Cela enregistre UserService auprès de l’injecteur racine de l’application. Désormais, toute requête similaire peut être satisfaite :

inject(UserService)

Une façon utile d’imaginer un fournisseur est comme une recette que Angular conserve en mémoire :

"When someone asks for UserService,
here is how you should provide it."

Enregistrement à la racine avec providedIn

@Injectable({
  providedIn: 'root'
})

Avec providedIn: 'root', le service appartient à l’injecteur de niveau racine. Angular crée une instance la première fois qu’on en a besoin et réutilise cette même instance pour toutes les requêtes ultérieures, ce qui en fait un singleton à l’échelle de l’application. Le service complet ressemble à ceci :

import { Injectable } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class UserService {
  getUserName() {
    return 'Rahul';
  }
}

Chaque composant peut alors le demander sans avoir à le lister dans un tableau providers :

userService = inject(UserService);

Pour la plupart des services applicatifs, c’est l’approche la plus simple et préférée. Elle présente également un effet secondaire utile : si personne n’injecte jamais ce service, les outils de compilation peuvent le supprimer du bundle, car son enregistrement se trouve directement sur le service plutôt que dans un module ou une composante.

Enregistrement d’un service sur une composante

Un fournisseur peut également être associé à une composante spécifique via son tableau providers :

@Component({
  selector: 'app-user',
  providers: [UserService],
  templateUrl: './user.html'
})
export class User {
}

Dans ce cas, UserService est fourni par l’injecteur propre à cette composante et non seulement par l’injecteur racine :

providedIn: 'root'

La différence entre ces deux modes d’enregistrement constitue le point de départ pour comprendre les injecteurs hiérarchiques.

Injecteurs hiérarchiques

Angular ne utilise pas un seul conteneur global. Les injecteurs forment un arbre qui reflète approximativement l’arbre des composants, avec l’injecteur racine en haut :

Root Injector
      │
      ├── App
      │
      ├── Component A
      │
      │    └── Child Component
      │
      └── Component B

Un service fourni à la racine est disponible presque partout. Cependant, un composant peut enregistrer son propre fournisseur pour ce même service :

@Component({
  selector: 'app-user',
  providers: [UserService],
  templateUrl: './user.html'
})
export class User {
}

Angular maintient alors un fournisseur lié à l’injecteur de ce composant. Lorsque le composant ou l’un de ses descendants demande UserService, Angular utilise l’instance correspondant à ce niveau. Chaque instance du composant dispose de sa propre copie du service, créée avec le composant et détruite avec lui. Les composants situés ailleurs dans l’arbre continuent de voir l’instance racine.

Pourquoi plusieurs niveaux sont utiles

Considérez un SettingsService. La plupart des écrans devraient partager un même ensemble de paramètres, il est donc logique de le fournir à la racine :

Root
 │
 ├── Home
 ├── Products
 └── Profile

Supposons maintenant que la zone d’administration ait ses propres paramètres qui ne doivent pas s’infiltrer dans le reste de l’application :

Root
 │
 ├── Home
 │
 └── Admin
       │
       └── Admin Settings Service

En fournissant ce service au composant d’administration, cette sous-arborescence reçoit une instance distincte tandis que tout le reste continue d’utiliser l’instance partagée. L’état par fonctionnalité, l’état par dialogue et l’état par élément de liste sont autant d’utilisations courantes de ce schéma, et aucune d’elles n’est possible lorsque chaque composant appelle simplement new.

Comment Angular traite une demande

Lorsque Angular rencontre une telle demande :

userService = inject(UserService);

il cherche un fournisseur en partant de l’injecteur le plus proche du demandeur et en se déplaçant vers l’extérieur :

Component requests UserService
             ↓
  Check current injector
             ↓
  Provider found?
       ↙           ↘
     Yes            No
      ↓              ↓
Provide it      Look at parent
                     ↓
              Continue upward

Le premier injecteur qui trouve un fournisseur correspondant gagne. Si la recherche atteint le sommet sans en trouver aucun, Angular génère une erreur de DI, généralement signalée sous la forme NullInjectorError: Aucun fournisseur pour UserService. C’est pourquoi chaque service injectable doit être enregistré à un endroit accessible par celui qui le demande.

Injection de plusieurs dépendances

Un composant peut dépendre du nombre de services dont il a besoin. Ici, un composant demande à la fois un service utilisateur et un service de notification :

import { Component, inject } from '@angular/core';
import { UserService } from './user.service';
import { NotificationService } from './notification.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  userService = inject(UserService);
  notificationService = inject(NotificationService);
}

Une méthode peut ensuite les combiner, par exemple pour saluer l’utilisateur actuel :

showWelcomeMessage() {
const name = this.userService.getUserName();
  this.notificationService.show(
    `Welcome ${name}`
  );
}

Aucun de ces services n’est créé par le composant ; Angular les construit et les injecte tous deux.

Les services et la DI sont liés mais distincts

Il est facile d’utiliser ces deux termes de manière interchangeable, mais ils décrivent des choses différentes. Un service est une classe qui contient de la logique réutilisable ou un état partagé. L’injection de dépendances est le mécanisme qui fournit ce service aux parties de l’application qui en ont besoin :

Service
  ↓
Provides functionality

Dependency Injection
  ↓
Provides the service where needed
Component
  ↓
Uses the service

Appliqué à un service concret :

UserService
    ↓
Angular DI
    ↓
User Component
    ↓
Uses UserService

Le fait de maintenir les rôles séparés permet de modifier la manière ou le lieu par lequel un service est fourni sans toucher aux composants qui l’utilisent.

Combinaison de DI et de signaux

La DI s’accorde naturellement avec les signaux pour gérer un état réactif partagé. Le service ci-dessous possède un signal de compteur et une méthode qui le met à jour :

import { Injectable, signal } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class CounterService {
  count = signal(0);
  increase() {
    this.count.update(value => value + 1);
  }
}

Un composant injecte le service comme n’importe quel autre :

import { Component, inject } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  counterService = inject(CounterService);
}

Son modèle lit le signal en l’appelant, et le bouton appelle la méthode du service :

<h1>Count: {{ counterService.count() }}</h1>
<button (click)="counterService.increase()">
  Increase
</button>

Trois concepts coopèrent ici :

Dependency Injection
        ↓
    CounterService
        ↓
    Signal
        ↓
    Component
        ↓
    Template

Le service gère l’état, DI met à disposition la même instance de ce service à tous les consommateurs, et le signal assure que le modèle se met à jour lorsque la valeur change. Comme CounterService est fourni au niveau racine, chaque composant qui l’injecte voit le même compteur. Le fournir au niveau d’un composant donnerait à chaque sous-arbre son propre compteur indépendant.

Ce que DI vous apporte

Les avantages s’accumulent à mesure que l’application grandit :

  • Organisation : les composants n’ont pas besoin de savoir comment leurs dépendances sont créées ni ce dont ces dernières ont besoin.
  • Réutilisation : un seul service peut servir de nombreux composants.
  • Testabilité : lors des tests, vous pouvez fournir un fournisseur fictif ou simulé à la place du service réel, par exemple un qui renvoie des données fixes au lieu d’appeler une API.
  • Flexibilité : la hiérarchie des injecteurs vous permet de choisir entre une instance partagée et des instances ciblées.
  • Découplage : un composant dépend de ce que propose un service, et non de la manière dont il est construit.
  • Une application de shopping à titre d’exemple

    Imaginez une boutique en ligne disposant de quelques services, chacun ayant une seule responsabilité :

    ProductService
        ↓
    Gets products from API
        ↓
    CartService
        ↓
    Manages cart state
    AuthService
        ↓
    Manages authentication
    NotificationService
        ↓
    Displays notifications
    

    Chaque composant n’injecte alors que ce dont il a besoin ; une barre de navigation, par exemple, pourrait nécessiter à la fois l’authentification et l’état du panier :

    ProductComponent
          ↓
    ProductService
          ↓
    CartComponent
          ↓
    CartService
    Navbar
          ↓
    AuthService
          ↓
    CartService
    

    Angular relie tout cela entre eux, et chaque composant reste concentré sur son propre interface utilisateur plutôt que sur la création et la gestion des objets qui se trouvent derrière elle.

    L’erreur la plus courante : un service sans fournisseur

    Les débutants écrivent souvent une simple classe :

    export class UserService {
    }
    

    puis tentent de l’injecter :

    userService = inject(UserService);
    

    Puisque aucun fournisseur n’est enregistré nulle part, Angular ne dispose d’aucune méthode pour gérer cette classe et l’injection échoue avec l’erreur de fournisseur manquant mentionnée précédemment. Pour un service utilisé dans toute l’application, la solution consiste à ajouter le décorateur avec une registration racine :

    @Injectable({
      providedIn: 'root'
    })
    export class UserService {
    }
    

    Désormais, l’injecteur racine sait comment créer le service. Sinon, vous pouvez lister la classe dans l’array providers d’un composant si vous souhaitez une instance ciblée.

    Résumé

    Dans Angular moderne, une classe obtient une dépendance à l’aide de inject():

    userService = inject(UserService);
    

    et un service se rend disponible dans toute l’application grâce à :

    @Injectable({
      providedIn: 'root'
    })
    

    La propriété providedIn détermine l’endroit où le service est enregistré, et le système d’injection hiérarchique vous permet de partager une seule instance dans toute l’application ou d’affecter des instances distinctes à un composant et à ses descendants. Le flux complet de la requête est le suivant :

    Component
        ↓
    Requests Dependency
        ↓
    Angular DI
        ↓
    Provider
        ↓
    Service
    

    Et avec des signaux ajoutés pour gérer l’état réactif :

    Component
        ↓
    inject()
        ↓
    Service
        ↓
    Signal
        ↓
    Reactive UI
    

    Points clés

    • Les composants demandent des dépendances ; ils ne les créent pas. Angular trouve un fournisseur et fournit l’instance correspondante.
    • Appelez inject() uniquement dans un contexte d’injection, comme lors de l’initialisation d’un champ ou dans un constructeur.
    • providedIn: 'root' génère un singleton créé de manière différée et pouvant être supprimé lors du minification, ce qui constitue la valeur par défaut appropriée pour les services utilisés dans toute l’application.
    • Une entrée providers au niveau du composant crée une instance séparée pour ce composant et son sous-arbre.
  • La résolution remonte le arbre des injecteurs ; l’absence d’un fournisseur à n’importe quel point de ce chemin entraîne une NullInjectorError.
  • Les signaux DI plus constituent un modèle simple et efficace pour gérer l’état partagé, et le remplacement des fournisseurs permet d’isoler les composants lors des tests.