05 lis 2024
8 min

Cracking Angular DI: Ukryte Warstwy Injectorów

Dependency Injection (DI) to przydatne i popularne narzędzie ułatwiające proces developmentu aplikacji. Uproszczony zostaje proces injectowania zależności do komponentów, DI usprawnia pisanie testów oraz wspiera zasadę odwróconego sterowania. Jednak wielu programistów nie wykorzystuje w pełni DI, ponieważ mogą nie rozumieć, jak działa on za kulisami. W tym artykule zbadamy jak działa mechanizm DI, hierarchię wstrzykiwaczy i to, jak działają one wraz z komponentami standalone

Injector 101

Aby w pełni zrozumieć, jak DI działa w Angular, kluczowe jest zrozumienie, w jaki sposób wstrzykiwacze świadczą usługi. Oto uproszczony algorytm:

  1. Angular używa bieżącego  wstrzykiwacza
  2. Injector.get(token)jest wywoływana wraz z tokenem usługi
  1. Jeśli wstrzykiwacz znajdzie token, tworzy i zwraca nową instancję usługi lub udostępnia istniejącą instancję.

Rzućmy okiem na przykład własnej implementacji wstrzykiwacza:

type InjectionToken = string; // unique identifier

abstract class Injector {
  abstract get(token: InjectionToken): any; // function for getting a service
}

type Record = {
  factory: () => any; // factory for creating a service
  value: any; // existed service
}

export class ModuleInjector extends Injector {
  private records: Map<InjectionToken, Record>; // responsible for storing services
  
  constructor(providers: Array<[InjectionToken, Record]>) {
      this.records = new Map(providers);
  }
 
  get(token: InjectionToken): any {
      if (!this.records.has(token)) { // if token is not found then throw an error
          throw new Error(`Could not find the ${token}`);
      }
      
      const record = this.records.get(token);
      
      if (!record.value) { // if an instance is not created then just create it
          record.value = record.factory();
      }
      
      return record.value;
  }
}

Angular gromadzi wszystkie usługi dostawców w modułach, komponentach i klasach oznaczonych dekoratorem @Injectable({providedIn: …}). Następnie wstrzykiwacz tworzy mapę, która pozwala nam pobrać instancje tych usług. Pod maską proces Angulara wygląda mniej więcej tak:

// gathering all providers and creating an injector
const injector = new Injector([
    ['SomeService', () => new SomeService()], 
    ['AnotherService', () => new AnotherService()]
    ...
]);

// when a component creates, then Injector.get is called
injector.get('SomeService') // => SomeService instance 

Jednak Angular nie opiera się na jednym wstrzykiwaczu — wykorzystuje całą hierarchię wstrzykiwaczy. To drzewo wstrzykiwaczy jest tworzone podczas procesu ładowania aplikacji. Jeśli bieżący wstrzykiwacz nie zawiera wymaganego tokena, Angular próbuje go znaleźć w wstrzykiwaczu nadrzędnym. Zaimplementujmy tę logikę w naszym własnym wstrzykiwaczu:

export class ModuleInjector extends Injector {
  private records: Map<InjectionToken, Record>;
  private parent: Injector;
  
  constructor(
      providers: Array<[InjectionToken, Record]>,
      parentInjector: Injector
  ) {
      this.records = new Map(providers);
      this.parent = parentInjector; // save the parent injector
  }
 
  get(token: InjectionToken): any {
      if (!this.records.has(token)) {
          // attempt find a token in the parent injector
          return this.parent.get(token);
      }
      ...
  }
}

Kiedy Angular tworzy wstrzykiwacz, zapisuje referencję do wstrzykiwacza nadrzędnego. Jeśli bieżący wstrzykiwacz nie zawiera tokena, Angular szuka go w wstrzykiwaczu nadrzędnym. Ale co się stanie, jeśli token nie zostanie znaleziony w żadnym z wstrzykiwaczy? W takim przypadku użyty zostanie specjalny NullInjector.

export class NullInjector implements Injector {
  get(token: ProviderToken): any {
    const error = new Error(`NullInjectorError: No provider for ${stringify(token)}!`);
    error.name = 'NullInjectorError';
    throw error;
  }
}

Angular zapewnia NullInjector jako korzeń drzewa wtryskiwaczy. Jeśli token nie zostanie znaleziony w łańcuchu wstrzykiwaczy, NullInjector zgłasza znany błąd: NullInjectorError: Brak dostawcy dla MyService!.

Skoro już doszliśmy do tej koncepcji, przyjrzyjmy się różnym typom wstrzykiwaczy w Angularze i sposobom ich połączenia. Warto zauważyć, że najpierw przyjrzymy się hierarchii wstrzykiwaczy przed opublikowaniem wersji 14tej Angulara, a następnie sprawdzimy, jak zmieniła się wraz z wprowadzeniem samodzielnych komponentów.

Hierarchia wstrzykiwaczy

Default Injectors

Podczas procesu ładowania aplikacji tworzone są trzy wstrzykiwaczy:

  • NullInjector: Zgłasza błąd, jeśli token nie zostanie znaleziony.
  • Platform Injector: Odpowiedzialny za usługi, które mogą być udostępniane przez wiele aplikacji w ramach tego samego projektu Angular
  • Root Injector: Przechowuje domyślne usługi Angulara, a także usługi świadczone przy użyciu dekoratora @Injectable({ ProvideIn: 'root' }) lub zdefiniowane we właściwości Providers metadanych AppModule.

Kiedy próbujemy uzyskać usługę, Angular najpierw przeszukuje wstrzykiwacz root, a następnie wstrzykiwacz platformy. Jeśli usługa nadal nie zostanie znaleziona, NullInjector zgłasza błąd. Może to wyglądać tak na schemacie:

Należy wziąć pod uwagę mylący aspekt: ​​jeśli moduł z dostawcami zostanie zaimportowany do AppModule, osobny wtryskiwacz nie zostanie utworzony. Zamiast tego jego usługi zostaną dodane do wstrzykiwacza root. 

@NgModule({
    providers: [MyService]
})
export class MyModule {}

@NgModule({
    imports: [MyModule],
    providers: [Service]
})
export class AppModule {}

// as a result the root injector contains two services:
// [Service, MyService]

Kiedy uczyłem się Angulara, nie był to dla mnie oczywisty punkt – zakładałem, że każdy moduł będzie tworzył swój własny wtryskiwacz.

Wstrzykiwacz leniwie ładowanych modułów

Oprócz trzech domyślnych wstrzykiwaczy, Angular tworzy również osobny wstrzykiwacz dla każdego leniwie ładowanego modułu.

@NgModule({
    providers: [MyService]
})
export class LazyModule {
}

export const ROUTES: Route[] = [
  {
      path: 'lazy',
      loadChildren: () => import('./lazy-module').then(mod => mod.LazyModule)
  },
]

Kiedy udostępniasz usługę we właściwości providers modułu ładowanego z opóźnieniem, wstrzykiwacz modułu utworzy instancję usługi dla komponentów należących do tego modułu. Pozostała część aplikacji nie będzie świadoma istnienia tej usługi.

Jeśli usługa zostanie wstrzyknięta do komponentu w module leniwie ładowanym, wyszukiwanie rozpocznie się od wtryskiwacza tego modułu. Jeśli usługa nie zostanie znaleziona, wyszukiwanie będzie kontynuowane według standardowego łańcucha: Root Injector → Platform Injector → NullInjector

Node Injector

Omówiliśmy już wstrzykiwacze modułów, ale Angular używa również innego typu wstrzykiwacza: wstrzykiwacza elementów.

  • Główny komponent zawsze tworzy własny wstrzykiwacz
  • Node injectors  są tworzone dla każdego tagu HTML, który służy jako selektor komponentu lub jako host dla dyrektyw.

Aby dodać usługę do wstrzykiwacza komponentu, musimy ją udostępnić w właściwości providers lub viewProviders w metadanych komponentu.

@Component({
  selector: 'app-child',
  template: '',
  providers: [Service], // provide Service to NodeInjector
  // or
  // viewProviders: [Service]
})
export class ChildComponent {
    constructor(
        private service: Service  // injected from NodeInjector
    ) {
    }
}

Główna różnica między wstrzykiwaczem elementów a wstrzykiwaczem modułu polega na tym, że wstrzykiwacz elementów jest tworzony i niszczony razem z komponentem. W rezultacie usługi należące do wstrzykiwacza elementów są tworzone i niszczone w ten sam sposób.

Wstrzykiwacze elementów mają swoją własną hierarchię, podobną do wstrzykiwaczy modułów. Wyszukiwanie usługi zaczyna się od bieżącego wstrzykiwacza elementu i kontynuuje się w górę przez komponenty nadrzędne, aż usługa zostanie znaleziona lub osiągnięty zostanie główny komponent. Rozważmy to drzewo komponentów:

<app-root>
    <app-parent>
        <app-child></app-child>
        <app-child></app-child>
    </app-parent>
</app-root>

Gdy usługa zostanie wstrzyknięta do komponentu app-child, łańcuch wyszukiwania będzie wyglądał następująco: app-childapp-parentapp-root.

Pełny obraz drzewa wstrzykiwaczy

@Component({...})
export class Component {
    // how does it work?
    constructor(private readonly service: Service) {
    }
}

Gdy usługa jest wstrzyknięta do komponentu, Angular najpierw szuka jej w bieżącym wstrzykiwaczu elementu. Jeśli nie zostanie znaleziona, wyszukiwanie kontynuuje się przez nadrzędne wstrzykiwacze, a następnie przez wstrzykiwacze modułów ładowanych leniwie, i wreszcie przez standardowy łańcuch: Root InjectorPlatform InjectorNullInjector. Ostatni z nich zgłasza błąd, jeśli żaden wstrzykiwacz nie zawiera tokenu usługi.

Przygotowałem przykład na StackBlitz, który szczegółowo omawia ten temat. Polecam zainstalować rozszerzenie Angular DevTools, aby samodzielnie obserwować drzewo wstrzykiwaczy bezpośrednio w narzędziach deweloperskich.

Jeśli kiedykolwiek poczujesz się zagubiony, możesz skorzystać ze ściągawki Chrisa Kohlera. Nie wahaj się z niej skorzystać w przypadku pytań dotyczących DI 

Standalone revolution

Environment Injector

Wraz z wprowadzeniem komponentów samodzielnych (standalone) w Angularze 14, hierarchia injectorów uległa zmianie. Angular teraz używa Environment Injector zamiast wstrzykiwaczy modułów. W  samodzielnej aplikacji nie ma modułów, dlatego zespół Angulara wybrał bardziej spójną terminologię. Jednak łańcuch wyszukiwania domyślnych injectorów pozostaje taki sam: Root Environment Injector → Platform Environment Injector → NullInjector.
Jeśli chcesz dostarczyć serwis do root injector, masz dwie opcje: użyć dekoratora @Injectable({ providedIn: 'root' }) albo skorzystać z ApplicationConfig, który zastępuje providerów z AppModule

export const appConfig: ApplicationConfig = {
  providers: [SomeService],
};

bootstrapApplication(AppComponent, appConfig).catch((err) =>
  console.error(err)
);

Route Environment Injector

W Angularze 14 wprowadzono nową funkcję loadComponent, która obsługuje lazy-loading komponentów, podobnie jak loadChildren działa dla modułów. Można by założyć, że loadComponent może być używane do utworzenia osobnego injectora, ale to założenie jest błędne. W rzeczywistości loadComponent nie tworzy nowego injectora.

export const ROUTES: Route[] = [
  {
      path: 'lazy',
      // it does not create a new injector :(
      loadComponent: () => import('./lazy.component').then(c => c.LazyComponent)
  },
]

Jeśli musisz utworzyć injector dla konkretnej ścieżki, podobnie jak w przypadku injectorów modułów ładowanych leniwie, możesz użyć właściwości providers w konfiguracji ścieżki. Ta funkcjonalność tworzy osobny injector dla danej ścieżki oraz jej tras podrzędnych, niezależnie od tego, czy trasa jest ładowana leniwie, czy nie.

export const ROUTES: Route[] = [
    {
        path: 'route-with-providers',
        component: ChildComponent,
        // it does create a new injector for the route
        providers: [SomeService],
        children: [...]
    },
]

Pełny proces wyszukiwania serwisu wygląda następująco:

Sprawdź przykład na StackBlitz dla tego przypadku.

Wsteczna kompatybilność z NgModule

Zespół Angulara zdaje sobie sprawę, że migracja do komponentów samodzielnych  zajmuje nieco czasu, dlatego dodano wsteczną kompatybilność z podejściem NgModule. Możesz zaimportować zarówno komponent, jak i moduł wewnątrz komponentu samodzielnego.

@Component({
  standalone: true,
  selector: 'app-child',
  imports: [
    SomeComponent, // standalone component
    SomeModule // "old" NgModule
  ], 
  template: ``,
})
export class ChildComponent {
}

Ta funkcja ułatwia migrację do nowego typu komponentu, ale może również prowadzić do nieoczekiwanego zachowania. Problemy pojawiają się, gdy moduł z serwisami zostanie zaimportowany do komponentu samodzielnego. Ponieważ Angular traktuje komponenty samodzielne jako niezależne bloki budujące aplikację, dziwne byłoby, gdyby te serwisy zostały dodane do root injectora. Komponenty muszą enkapsulować tę logikę.

Aby rozwiązać ten problem, Angular tworzy oddzielny "injector-wrapper" dla komponentów samodzielnych, jeśli importują one jakiekolwiek moduły, niezależnie od tego, czy moduły mają providery, czy nie. Ten "injector-wrapper" zbiera wszystkie serwisy z zaimportowanych modułów i przechowuje je wewnątrz siebie.

Injector-wrapper jest tworzony w trzech przypadkach:

  1. Podczas procesu bootstrapowania aplikacji: Jeśli komponent główny zawiera moduły lub używa komponentu z modułami w środku.
  2. Dla dynamicznie tworzonych komponentów : Jeśli komponent zawiera moduły lub używa komponentu z modułami w środku.
  3. Dla komponentów routowanych: Jeśli komponent zawiera moduły lub używa komponentu z modułami w środku.

Rozważmy przypadki po kolei

  1. During Bootstrap

Jeśli zaimportujemy komponent z modułami do naszego AppComponent, zostanie utworzony "injector-wrapper".

@Component({
  standalone: true,
  selector: 'app-root',
  imports: [ChildComponent], // the component with modules inside
  template: `
    <app-child /><app-child />
  `,
})
export class AppComponent {
}

Hierarchia injectorów środowiskowych będzie wyglądać tak:

  1. Dynamicznie tworzony komponent

Injector-wrapper zostanie utworzony, gdy dynamicznie tworzony komponent zawiera moduły lub używa komponentu z modułami.

@Component({
  ...
})
export class AppComponent {
  click(): void {
    // dynamically creation of component with modules
    const compRef = this.viewContainerRef.createComponent(ChildComponent);
    compRef.changeDetectorRef.detectChanges();
  }
}
  1. Routed component

Jeśli router prowadzi do komponentu, który zawiera moduły lub używa komponentu z modułami, również zostanie utworzony "injector-wrapper".

export const ROUTES: Route[] = [
    {
        path: 'child',
        // the component with modules inside
        component: ChildComponent,
    },
]

Jeśli masz jakiekolwiek pytania dotyczące przykładów, proszę kliknij link do StackBlitz. Przygotowałem te przypadki, które możesz samodzielnie przestudiować przy pomocy narzędzi Angular Devtools.

Zamiast zakończenia

Dziękuję za uwagę. Zamiast podsumowania, chciałbym poznać Twoje zdanie. Niektórzy uważają, że wszystkie serwisy powinny znajdować się w root injectorze, aby nie musieli martwić się o hierarchię injectorów w Angularze. Jednak ja uważam, że powinniśmy podejść do tego inaczej. Co o tym sądzisz?

Nie wahaj się ze mną skontaktować, jeśli masz jakiekolwiek pytania!

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.