Привет. Меня зовут Дмитрий, я фронтенд-разработчик в компании «Цифровая индустриальная платформа». В своей работе мне часто приходится использовать Dependency Injection (DI) в Angular. Это мощный и популярный инструмент, который упрощает работу с зависимостями в наших приложениях. Он позволяет легко интегрировать необходимые сущности в компоненты, упрощает процесс тестирования и поддерживает принцип инверсии зависимостей. Однако часто мы не используем все возможности DI, потому что не знаем, как он работает под капотом. Давайте разберемся, как функционирует DI, что такое иерархия инжекторов и какие изменения принесла версия Angular 14.
Injector 101. Как работает инжектор
Чтобы составить общую картину работы DI в Angular, важно понять, как инжекторы находят и создают сервисы. Вот упрощенный алгоритм:
Angular берет текущий
Injector
.Вызывает у него метод
Injector.get(token)
, передавая токен сервиса.Если
Injector
находит у себя этот токен, он либо создает и возвращает новый экземпляр сервиса, либо возвращает уже ранее созданный экземпляр.
Рассмотрим примерную реализацию инжектора:
type ProviderToken = string; // уникальный идентификатор
abstract class Injector {
abstract get(token: ProviderToken): any; // функция для получения сервиса
}
type Record = {
factory: () => any; // функция для создания сервиса
value: any; // созданный сервис
}
export class ModuleInjector extends Injector {
private records: Map<ProviderToken, Record>; // отвечает за хранение сервисов
constructor(providers: Array<[ProviderToken, Record]>) {
this.records = new Map(providers);
}
get(token: ProviderToken): any {
if (!this.records.has(token)) { // если токена нет, то кидаем ошибку
throw new Error(`Could not find the ${token}`);
}
const record = this.records.get(token);
if (!record.value) { // если сервис не создан, то создаем его
record.value = record.factory();
}
return record.value;
}
}
Angular при создании инжектора собирает сервисы из providers
в модулях и компонентах, а также классов, помеченных декоратором @Injectable({...})
. Затем инжектор создает из них мапу, которая позволяет получать экземпляр сервиса по запросу. Вот так это упрощенно работает в Angular под капотом:
// собираем провайдеры и создаем инжектор
const injector = new Injector([
['SomeService', () => new SomeService()],
['AnotherService', () => new AnotherService()]
...
]);
// когда создается компонент с этим сервисом, вызывается injector.get
injector.get('SomeService') // => SomeService instance
Однако в Angular существует не один инжектор, а целая иерархия инжекторов. При создании приложения формируется дерево инжекторов, которое может расширяться. В случае, если токен не найден в текущем инжекторе, Angular пытается найти его в инжекторе-родителе. Давайте добавим эту логику в наш инжектор:
export class ModuleInjector extends Injector {
private records: Map<ProviderToken, Record>;
private parent: Injector;
constructor(providers: Array<[ProviderToken, Record]>, parentInjector: Injector) {
this.records = new Map(providers);
this.parent = parentInjector; // сохраняем инжектор-родитель
}
get(token: ProviderToken): any {
if (!this.records.has(token)) {
return this.parent.get(token); // пытаемся найти в родителе
}
...
}
}
При создании инжектора Angular передает не только массив токенов и записей, но еще и родительский инжектор. Что же будет, если токен не будет найден ни в одном из инжекторов? Для такого случая в Angular существует специальный 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 передает NullInjector
как родителя для первого созданного инжектора. Если токен не найден в цепочке инжекторов, то NullInjector
выбрасывает всем знакомую ошибку “NullInjectorError: No provider for MyService!”.
Теперь, с пониманием этой концепции, давайте рассмотрим, какие инжекторы существуют в Angular и как они связаны между собой. Важно отметить, что сначала мы будем рассматривать, как работала иерархия инжекторов до 14-й версии Angular, а далее посмотрим, как она изменилась.
Иерархия инжекторов
Default Injectors
При запуске приложения создаются три инжектора:
NULL injector – выбрасывает ошибку, если токен не найден, за исключением случая с @Optional.
Platform injector – отвечает за сервисы, которые могут использоваться несколькими приложениями в рамках одного проекта Angular.
Root injector – хранит дефолтные сервисы Angular и сервисы, которые помечены декоратором
@Injectable({providedIn: ‘root’})
или указаны в свойствеproviders
в метаданныхAppModule
.
При инжекте сервиса, Angular сначала ищет его в рутовом инжекторе, затем в платформенном. Если сервис не найден, NULL инжектор бросает ошибку. Схематично это можно представить так
Интересно, что если модуль с providers
импортируется в AppModule
, он не создает отдельный инжектор, а его провайдеры попадают в рутовый инжектор:
@NgModule({
providers: [AnotherService]
})
export class AnotherModule {}
@NgModule({
imports: [AnotherModule],
providers: [Service]
})
// root инжектор будет хранить два сервиса: [Service, AnotherService]
export class AppModule {}
Эта ситуация была для меня неочевидной при знакомстве с Angular. Я неправильно считал, что каждый модуль создает свой инжектор.
Lazy-loading Module Injector
Помимо трех основных инжекторов, Angular создает свой инжектор для каждого lazy-loading модуля.
@NgModule({
providers: [MyService]
})
export class LazyModule {
}
export const ROUTES: Route[] = [
{
path: 'lazy',
loadChildren: () => import('./lazy-module')
.then(mod => mod.LazyModule)
},
]
Если указать сервис в провайдерах lazy-loading модуля, то его инжектор создаст свой экземпляр сервиса для компонентов этого модуля. Остальные части приложения ничего не будут знать про этот сервис.
Если заинжектить сервис в компонент, который принадлежит lazy-loading модулю, то поиск сервиса будет начинаться с инжектора этого модуля, а далее перейдет к цепочке Root Injector
→ Platform Injector
→ Null Injector
:
Node Injector
Кроме модульных инжекторов в Angular существуют node (element) инжекторы.
Рутовый компонент всегда создает node инжектор для себя.
Node инжекторы создаются для каждого тега, соответствующего Angular компоненту, или для любого тега, на который применена директива.
Для добавления сервиса в node инжектор, его нужно добавить в providers
или viewProviders
метадаты компонента:
@Component({
selector: 'app-child',
template: '',
providers: [Service], // provide to NodeInjector
// or
viewProviders: [Service]
})
export class ChildComponent {
constructor(
private service: Service // injected from NodeInjector
) {
}
}
Отличие node инжектора от модульного инжектора в том, что он создается и умирает вместе с компонентом, следовательно сервисы в node инжекторе создаются и умирают вместе с ним.
У node инжекторов есть своя иерархия. Цепочка поиска сервиса начинается с текущего node инжектора и идет к родительскому компоненту, пока не дойдет до рутового node инжектора. Рассмотрим такое дерево компонентов:
<app-root>
<app-parent>
<app-child></app-child>
<app-child></app-child>
</app-parent>
</app-root>
Если сервис инжектится в app-child
компонент, то цепочка поиска будет такой: app-child
→ app-parent
→ app-root
:
Полная картина
@Component({...})
export class Component {
// как это работает?
constructor(private readonly service: Service) {
}
}
Итак, когда сервис инжектится в компонент, Angular сначала пытается найти его в текущем node инжекторе, затем идет вверх по иерархии node инжекторов, потом переходит к модульным инжекторам, начиная с lazy-loading модулей (если они есть), далее к рутовому инжектору, затем к платформенному инжектору, и, если ничего не найдено, NULL инжектор выбрасывает ошибку:
Я подготовил пример на StackBlitz, где можно подробно рассмотреть, как это работает. Рекомендую установить расширение Angular DevTools в браузере, чтобы можно было изучить дерево инжекторов в инструментах разработчика (Ctrl+Shift+I → Angular → Injector Tree).
Также хочу посоветовать интересную шпаргалку от Криса Кохлера (Chris Kohler), к которой всегда можно обращаться с вопросами по DI.
Standalone революция
Environment Injector
С приходом standalone компонентов в Angular 14-й версии ситуация в иерархии инжекторов изменилась. Вместо модульного инжектора теперь используется EnvironmentInjector
. Так как в standalone приложении нет модулей, то Angular команда решила использовать более согласованное название, но на принцип работы это не повлияло. Иерархия инжекторов все та же: Root Environment Injector
→ Platform Environment Injector
→ Null Injector
.
Чтобы запровайдить сервис в рутовый инжектор, теперь можно использовать следующие варианты:
// №1 - старый добрый декоратор Injectable
@Injectable({
providedIn: 'root'
})
export class SomeService {}
// №2 - передать сервис в конфиг бутстрапа приложения
export const appConfig: ApplicationConfig = {
providers: [SomeService],
};
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);
Route Environment Injector
В Angular 14 появилась функция loadComponent
для lazy-loading компонентов, которая похожа на loadChildren
. Кажется, что можно было бы использовать loadComponent
для создания отдельного инжектора, как это делается с lazy-loading модулем и его инжектором. Однако это предположение ошибочно. В действительности loadComponent
не создает новый инжектор.
export const ROUTES: Route[] = [
{
path: 'lazy',
// не создаю новый инжектор :(
loadComponent: () => import('./lazy.component').then(c => c.LazyComponent)
},
]
Если необходимо создать инжектор для конкретного маршрута, аналогично LazyLoadingModule Injector
можно использовать свойство providers
в конфигурации маршрута, что создаст отдельный инжектор. Это будет работать и для обычных маршрутов, и для lazy-loading маршрутов.
export const ROUTES: Route[] = [
{
path: 'route-with-providers',
component: ChildComponent,
providers: [SomeService], // тут создается новый инжектор
children: [...]
},
]
Данный инжектор будет применяться для маршрута route-with-providers
и всех его детей:
Обратная совместимость с NgModule
Команда Angular осознает, что переход на standalone компоненты будет постепенным, поэтому они добавили совместимость с подходом NgModule
. В standalone компоненты можно импортировать модули наряду с компонентами:
@Component({
standalone: true,
selector: 'app-child',
imports: [SomeModule], // импорт модуля
template: ``,
})
export class ChildComponent {
}
Это облегчает миграцию на новые типы компонентов, но также приводит к некоторым трудностям. Проблема возникает, когда у standalone компонентов есть модули, которые содержат сервисы. Angular рассматривает standalone компоненты как независимые строительные блоки приложения. Было бы странно, если бы эти компоненты добавляли свои сервисы в рутовый инжектор.
Чтобы решить эту проблему, Angular теперь создает для standalone компонентов отдельный инжектор-обертку, если эти компоненты импортируют модули, независимо от того, содержат ли эти модули сервисы или нет. Этот инжектор-обертка собирает сервисы из импортированных модулей и включает их в себя.
Инжектор-обертка создается в следующих случаях:
при старте приложения, если в рутовом компоненте используется компонент с модулем;
для компонентов, которые создаются динамически и имеют модули;
при использовании роутинга на компоненты с модулями;
Рассмотрим эти случаи на примерах.
#1. Возьмем AppComponent и добавим ему в импорты компонент с модулем.
@Component({
standalone: true,
selector: 'app-root',
imports: [ChildComponent], // компонент с модулем в импортах
template: `
<app-child /><app-child />
`,
})
export class AppComponent {
}
Иерархия environment инжекторов будет выглядеть таким образом:
#2. Отдельный инжектор будет создаваться при динамическом создании компонентов с модулем.
@Component({
...
})
export class AppComponent {
click(): void {
// динамическое создание компонента с модулем в импортах
const compRef = this.viewContainerRef.createComponent(ChildComponent);
compRef.changeDetectorRef.detectChanges();
}
}
#3. Если на компонент с модулем будет вести маршрут, то аналогично будет создан отдельный инжектор-обертка.
export const ROUTES: Route[] = [
{
path: 'child',
// компонент с модулем в импортах
component: ChildComponent,
},
]
Для наглядности можно рассмотреть StackBlitz пример, где собраны варианты создания инжектора-обертки.
Вместо заключения
Спасибо, что дочитали мою статью до конца. Вместо того, чтобы подводить итоги, я хотел бы узнать ваше мнение. Кто-то считает, что все сервисы можно помещать в рутовый инжектор и не переживать насчет иерархии инжекторов в Angular. Я придерживаюсь иного мнения. А что вы думаете об этом?
И спасибо за внимание!
Комментарии (4)
Fidget
30.07.2024 14:38+2Отличная статья. Я бы ещё добавил абзац про tree shaking. Или это тема для отдельной статьи?
ItNoN Автор
30.07.2024 14:38+2Спасибо большое. Добавить про tree shaking в целом хорошая идея. Но думаю, что можно оставить на еще одну статью.
У нас была мысль написать статью про рефлексию, и то как Angular работает с декоратором Injectable. Там, мне кажется, инфу про tree shaking было бы отлично видеть
Cherezzabo
Отличная статья с наглядными примерами и схемами! Спасибо за такое!
Тоже придерживаюсь мнения, что пихать все сервисы в рутовый инжектор - это зло, с которым примеряешься, если не понимаешь, как оно там работает под капотом.
Применять такой подход в больших, развесистых Angular приложениях - это обрекать себя на сражение с неявными утечками памяти и мучительное развязывание цикличностей, когда есть не один десяток зависимых и со зависимых сервисов. Не все должно быть синглтоном, хотя Ангуляр (даже в части плохо расписанной документации по этой теме) как будто бы специально подталкивает просто все делать синглтоном в рутовом скоупе.
Я бы хотел спросить, каким еще средствами, методиками, подходами вы пользуетесь, чтобы следить за качеством и, главное, структурой дерева зависимостей внутри большого приложения/библиотеки Angular?
ItNoN Автор
Спасибо большое за лестный отзыв о статье. Рад, что вам понравилось.
Согласен, что Angular не особо пытается донести людям, что необязательно делать только синглтоны. Тут вы правы.
С 17ой версии можно смотреть дерево инжекторов в Angular DevTools, это очень помогает, крайне советую. Еще можно отметить, что мы следуем заложенной архитектуре, и таким образом внезапных зависимостей не появляется. Как то так.