AngularInDepth уходит от Medium. Эта статья, ее обновления и более свежие статьи размещены на новой платформе inDepth.dev

Модули Angular - довольно сложная тема. Команда Angular проделала большую работу, разместив довольно длинную страницу документации на NgModule, которую можно найти здесь. Он дает четкое объяснение по большинству тем, но некоторые области все еще отсутствуют и поэтому часто неправильно понимаются разработчиками. Я видел, как люди часто неверно истолковывают объяснение и неправильно используют рекомендации, потому что не понимают, как модули работают на нижнем уровне.

Также ознакомьтесь с моим докладом о модулях на NgConf. Узнайте, почему модули с отложенной загрузкой аналогичны модулям с активной загрузкой, а также как RouterModule работает под капотом.

В этой статье дается такое подробное объяснение и проясняются распространенные недоразумения, которые я вижу каждый день в отношении stackoverflow.

Я работаю адвокатом разработчиков в ag-Grid. Если вам интересно узнать о сетках данных или вы ищете идеальное решение для сетки данных Angular, попробуйте его с помощью руководства « Начать работу с сеткой Angular за 5 минут ». Я с радостью отвечу на любые ваши вопросы. И следите за мной, чтобы оставаться в курсе!

Инкапсуляция модуля

Angular вводит концепцию инкапсуляции модулей аналогично модулям ES. По сути, это означает, что декларируемые типы - компоненты, директивы и каналы - могут использоваться только компонентами, объявленными внутри этого модуля. Например, если я попытаюсь использовать a-comp из модуля A внутри компонента App из модуля App:

@Component({
  selector: 'my-app',
  template: `
      <h1>Hello {{name}}</h1>
      <a-comp></a-comp>
  `
})
export class AppComponent { }

Вы получите сообщение об ошибке:

Ошибки синтаксического анализа шаблона: "a-comp" - неизвестный элемент.

Это потому, что в модуле App не объявлено a-comp. Если я хочу использовать этот компонент, мне нужно импортировать модуль, в котором этот компонент определен. Сделать это можно так:

@NgModule({
  imports: [..., AModule]
})
export class AppModule { }

И здесь в игру вступает инкапсуляция. Чтобы эта настройка работала, модуль A должен объявить a-comp общедоступным, добавив его в массив exports:

@NgModule({
  ...
  declarations: [AComponent],
  exports: [AComponent]
})
export class AModule { }

То же самое касается других декларируемых типов - директив и каналов:

@NgModule({
  ...
  declarations: [
    PublicPipe, 
    PrivatePipe, 
    PublicDirective, 
    PrivateDirective
  ],
  exports: [PublicPipe, PublicDirective]
})
export class AModule {}

Обратите внимание, что для компонентов, добавленных в entryComponents, нет инкапсуляции. Если вы используете динамические представления и создание экземпляров динамических компонентов, как описано в Вот что вам нужно знать о динамических компонентах в Angular, вы можете использовать компоненты из модуля A, не добавляя их в массив exports. Конечно, вам все равно нужно будет импортировать модуль A.

Большинство начинающих разработчиков иногда думают, что для поставщиков также существует инкапсуляция. Но его нет. К провайдеру, объявленному в любом неленивом загружаемом модуле, можно получить доступ из любого места внутри приложения. И в следующей главе объясняется, почему.

Иерархия модулей

Самая большая путаница, связанная с импортированными модулями, заключается в том, что разработчики думают, что они создают иерархию. И, вероятно, разумно предположить, что модуль, который импортирует другие модули, становится родительским модулем для его импорта. Однако этого не происходит. Все модули объединяются на этапе компиляции. Таким образом, между импортируемым модулем и импортируемым модулем нет иерархической связи.

Как и в случае с компонентами, компилятор Angular генерирует фабрику для корневого модуля. Корневой модуль - это тот, который вы указываете в методе bootstrapModule в main.ts:

platformBrowserDynamic().bootstrapModule(AppModule);

Фабрика, которую генерирует компилятор Angular, использует функцию createNgModuleFactory, которая принимает:

  • ссылка на класс модуля
  • компоненты начальной загрузки
  • преобразователь фабрики компонентов с входными компонентами
  • фабрика определений с объединенными поставщиками модулей

Последние два пункта объясняют, почему нет инкапсуляции модулей для поставщиков и компонентов входа. Это потому, что после компиляции у вас не остается нескольких модулей. У вас только слитый модуль. И во время компиляции компилятор не может знать, где и как вы будете использовать провайдеры и динамические компоненты. Таким образом, он не может контролировать инкапсуляцию. Но при синтаксическом анализе шаблона компонента доступна эта информация, которая позволяет использовать частные декларируемые объекты - компоненты, директивы и каналы.

Давайте посмотрим на пример фабрики, созданной модулем. Предположим, у вас есть модули A и B, каждый из которых определяет одного поставщика и один компонент записи:

@NgModule({
  providers: [{provide: 'a', useValue: 'a'}],
  declarations: [AComponent],
  entryComponents: [AComponent]
})
export class AModule {}

@NgModule({
  providers: [{provide: 'b', useValue: 'b'}],
  declarations: [BComponent],
  entryComponents: [BComponent]
})
export class BModule {}

Корневой модуль App также определяет поставщика и корневой app компонент и импортирует модули A и B:

@NgModule({
  imports: [AModule, BModule],
  declarations: [AppComponent],
  providers: [{provide: 'root', useValue: 'root'}],
  bootstrap: [AppComponent]
})
export class AppModule {}

Когда компилятор создает фабрику модулей для корневого модуля App, он объединяет поставщиков из всех модулей вместе и создает фабрику только для этого одного объединенного модуля. Вот как выглядит эта фабрика:

createNgModuleFactory(
    // reference to the AppModule class
    AppModule,

    // reference to the AppComponent that is used
    // to bootstrap the application
    [AppComponent],

    // module definition with merged providers
    moduleDef([
        ...

        // reference to component factory resolver
        // with the merged entry components
        moduleProvideDef(512, jit_ComponentFactoryResolver_5, ..., [
            ComponentFactory_<BComponent>,
            ComponentFactory_<AComponent>,
            ComponentFactory_<AppComponent>
        ])

        // references to the merged module classes 
        // and their providers
        moduleProvideDef(512, AModule, AModule, []),
        moduleProvideDef(512, BModule, BModule, []),
        moduleProvideDef(512, AppModule, AppModule, []),
        moduleProvideDef(256, 'a', 'a', []),
        moduleProvideDef(256, 'b', 'b', []),
        moduleProvideDef(256, 'root', 'root', [])
]);

Вы можете видеть, что поставщики и компоненты ввода из всех модулей объединены и переданы в moduleDef функцию. Таким образом, независимо от того, сколько модулей вы импортируете, создается только одна фабрика с объединенными поставщиками. Эта фабрика используется для создания экземпляра модуля с собственным инжектором. А поскольку у нас есть только один объединенный модуль, Angular создаст один корневой инжектор с использованием этих провайдеров.

Теперь вы можете задаться вопросом, что произойдет, если вы определите два модуля с одним и тем же токеном провайдера?

Первое правило заключается в том, что поставщик, определенный в модуле, который импортирует другой модуль, всегда выигрывает. Давайте воспользуемся нашей настройкой и определим a провайдера в корневом модуле:

@NgModule({
  ...
  providers: [{provide: 'a', useValue: 'root'}],
})
export class AppModule {}

И давайте проверим завод:

  moduleDef([
     ...
     moduleProvideDef(256, 'a', 'root', []),
     moduleProvideDef(256, 'b', 'b', []),
 ]);

Вы можете видеть, что результирующая фабрика объединенных модулей содержит поставщика {provide: ‘a’, useValue: ‘root’} из модуля App, который переопределяет поставщика из модуля A, поскольку они используют один и тот же токен a.

Второе правило заключается в том, что поставщик из последнего импортированного модуля переопределяет поставщиков в предыдущих модулях, ожидающих импортирующего модуля (следует из первого правила). Давайте снова настроим нашу настройку и определим a провайдера в B модуле:

@NgModule({
  ...
  providers: [{provide: 'a', useValue: 'b'}],
})
export class BModule {}

Итак, теперь App modules импортирует модули A и B в следующем порядке:

@NgModule({
  imports: [AModule, BModule],
  ...
})
export class AppModule {}

и модуль B содержит того же поставщика, что и модуль A. Посмотрим на получившуюся фабрику:

moduleDef([
     ...
     moduleProvideDef(256, 'a', 'b', []),
     moduleProvideDef(256, 'root', 'root', []),
 ]);

Хорошо, это доказывает правило. Провайдер содержит значение b, указанное в модуле B. Теперь попробуем поменять местами модули при их импорте:

@NgModule({
  imports: [BModule, AModule],
  ...
})
export class AppModule {}

И давайте посмотрим на созданную фабрику:

moduleDef([
     ...
     moduleProvideDef(256, 'a', 'a', []),
     moduleProvideDef(256, 'root', 'root', []),
 ]);

Ага, все как ожидалось. Поскольку мы заменили модули местами, поставщик a из модуля A заменяет поставщика тем же токеном из модуля B.

Ленивые загружаемые модули

А теперь еще один запутанный момент - ленивая загрузка модулей. Вот что о них говорится в официальной документации:

Angular создает модуль с отложенной загрузкой со своим собственным инжектором, потомком корневого инжектора ... Таким образом, модуль с отложенной загрузкой, который импортирует этот общий модуль, создает свою собственную копию службы.

Итак, мы знаем, что Angular создает собственный инжектор для лениво загружаемых модулей. Это происходит потому, что Angular создает отдельную фабрику для каждого загруженного модуля. Это означает, что провайдеры, определенные в таком модуле, не объединяются в основной модуль-инжектор. Поэтому, если ленивый загружаемый модуль определяет поставщика с тем же токеном, что и в корневом модуле, Angular создаст новый экземпляр этой службы, даже если он уже есть в инжекторе основного модуля.

Таким образом, ленивые загружаемые модули создают иерархию, но это иерархия инжекторов, а не модулей. Все импортированные модули по-прежнему объединяются в одну фабрику во время компиляции точно так же, как и с неленивыми модулями.

Вот соответствующий исходный код из RouterConfigLoader, который загружает ленивые модули и создает иерархию инжекторов:

export class RouterConfigLoader {

  load(parentInjector, route) {
    ...
    const modFactory = this.loadModuleFactory(route.loadChildren);
    const module = modFactory.create(parentInjector);
  }

  private loadModuleFactory(loadChildren) {
    ...
    return this.loader.load(loadChildren)
  }
}

Вы можете увидеть в этой строке

const module = modFactory.create(parentInjector);

что создается новый экземпляр загруженного модуля и ему передается родительский инжектор.

Статические методы forRoot и forChild

Посмотрим, что говорят официальные документы:

Добавьте CoreModule.forRoot метод, который настраивает ядро ​​UserService… Вызов forRoot только в корневом модуле приложения, AppModule

Это разумная рекомендация, но, не понимая, почему вы должны это делать, вы можете получить такую ​​настройку:

@NgModule({
  imports: [
    SomeLibCarouselModule.forRoot(),
    SomeLibCheckboxModule.forRoot(),
    SomeLibCloseModule.forRoot(),
    SomeLibCollapseModule.forRoot(),
    SomeLibDatetimeModule.forRoot(),
    ...
  ]
})
export class SomeLibRootModule {...}

Где каждый импортированный модуль (CarouselModule, CheckboxModule и т. Д.) не определяет вообще никаких провайдеров. Я не вижу смысла использовать здесь forRoot. Давайте посмотрим, зачем нам вообще нужен этот forRoot метод.

Когда вы импортируете модуль, вы обычно используете ссылку на класс модуля:

@NgModule({ providers: [AService] })
export class A {}

@NgModule({ imports: [A] })
export class B {}

Таким образом, все провайдеры, определенные в модуле A, будут добавлены в корневой инжектор и будут доступны для всего приложения. Вы уже знаете, почему это происходит - потому что все поставщики модулей объединены, как я показал в первом разделе.

Angular также поддерживает другой способ регистрации модуля у провайдеров. Вместо передачи ссылки на класс модуля вы можете передать объект, реализующий интерфейс ModuleWithProviders:

interface ModuleWithProviders { 
   ngModule: Type<any>
   providers?: Provider[] 
}

Вот как мы можем использовать этот подход для нашего примера выше:

@NgModule({})
class A {}

const moduleWithProviders = {
    ngModule: A,
    providers: [AService]
};

@NgModule({
    imports: [moduleWithProviders]
})
export class B {}

И вместо того, чтобы напрямую импортировать и использовать ссылку на объект moduleWithProviders, лучше определить статический метод в классе модуля, который возвращает этот объект. Назовем этот метод forRoot и проведем рефакторинг нашего примера:

@NgModule({})
class A {
  static forRoot() {
    return {ngModule: A, providers: [AService]};
  }
}

@NgModule({
  imports: [A.forRoot()]
})
export class B {}

Это было просто для демонстрационных целей. В этом простом случае нет необходимости определять метод forRoot и модуль возврата с объектом провайдеров, поскольку один и тот же набор провайдеров определяется как модулем, так и модулем с объектом провайдеров. Однако это будет иметь смысл, когда мы захотим разделить наших поставщиков и определить различный набор поставщиков в зависимости от того, куда будет импортирован наш модуль.

Например, мы хотим предоставить глобальную службу A для модуля, загружаемого без отложенной загрузки, и службу B для модуля, загружаемого с помощью отложенной загрузки. Теперь имеет смысл использовать описанный выше подход. Мы будем использовать метод forRoot для возврата поставщиков для модуля с неленивой загрузкой и forChild для модуля с отложенной загрузкой:

@NgModule({})
class A {
  static forRoot() {
    return {ngModule: A, providers: [AService]};
  }
  static forChild() {
    return {ngModule: A, providers: [BService]};
  }
}

@NgModule({
  imports: [A.forRoot()]
})
export class NonLazyLoadedModule {}

@NgModule({
  imports: [A.forChild()]
})
export class LazyLoadedModule {}

Так как модули с неленильной загрузкой объединяются, поставщики, указанные в forRoot, будут доступны для всех приложений. Но поскольку модули с отложенной загрузкой имеют свои собственные инжекторы, поставщики, указанные в forChild, будут доступны для инъекций только внутри этого модуля с отложенной загрузкой.

Обратите внимание, что имена методов, которые вы используете для возврата структуры ModuleWithProviders, могут быть совершенно произвольными. Имена forChild и forRoot, которые я использовал в приведенных выше примерах, - это обычные имена, рекомендованные командой Angular и используемые в реализации RouterModule.

Итак, вернемся к нашему примеру выше:

@NgModule({
  imports: [
    SomeLibCarouselModule.forRoot(),
    SomeLibCheckboxModule.forRoot(),
    ...

Нет смысла реализовывать forRoot метод для модулей, которые определяют только поставщиков для всего приложения и не имеют специального подмножества для модулей с отложенной загрузкой. И еще больше сбивает с толку использование этих методов, если импортированный модуль вообще не определяет каких-либо поставщиков.

Используйте соглашение forRoot / forChild только для общих модулей с поставщиками, которые будут импортированы как в активные, так и в ленивые модули модуля.

Еще одна вещь, связанная с forRoot и forChild методами. Поскольку это простые методы, вы можете передавать любые параметры или дополнительных поставщиков при их вызове. Хорошим примером является RouterModule. Он определяет forRoot метод, который принимает как дополнительных поставщиков, так и конфигурацию:

export class RouterModule {
  static forRoot(routes: Routes, config?: ExtraOptions)

routes, который вы передаете методу, регистрируются с использованием токена ROUTES:

static forRoot(routes: Routes, config?: ExtraOptions) {
  return {
    ngModule: RouterModule,
    providers: [
      {provide: ROUTES, multi: true, useValue: routes}

А параметры, которые вы передаете в качестве второго параметра, используются для настройки других поставщиков:

static forRoot(routes: Routes, config?: ExtraOptions) {
  return {
    ngModule: RouterModule,
    providers: [
      {
        provide: PreloadingStrategy,
        useExisting: config.preloadingStrategy ?
          config.preloadingStrategy :
          NoPreloading
      }

Как видите, RouterModule использует методы forRoot и forChild для разделения набора поставщиков, а также для его настройки на основе переданных параметров.

Кеширование модуля

Время от времени в stackoverflow появляется новый вопрос от разработчика, обеспокоенного тем, что импорт модуля как в ленивый, так и в неленивый модуль приведет к дублированию кода модуля во время выполнения. Это понятное предположение. Но не о чем беспокоиться, так как все существующие загрузчики модулей кешируют загружаемый модуль.

Когда SystemJS загружает модуль, он помещает его в кеш. Когда в следующий раз будет запрос для этого модуля, он вернет его из кеша и не будет выполнять дополнительный сетевой запрос. Это процесс, который происходит с каждым модулем. Например, когда вы пишете компоненты Angular, вы импортируете декоратор Component из модуля angular/core:

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

Вы много раз ссылаетесь на пакет в приложении. Но SystemJS не загружает angular/core пакет каждый раз. Он загружает его один раз и кеширует.

Нечто подобное происходит с Webpack, если вы используете angular-cli или настраиваете Webpack самостоятельно. Он включает код модуля только один раз в связку и дает ему идентификатор. Все остальные модули импортируют символы из этого модуля, используя этот идентификатор.

Спасибо за прочтение! Если вам понравилась эта статья, нажмите кнопку хлопка под 👏. Это очень много значит для меня и помогает другим людям увидеть историю.

Чтобы узнать больше, подпишитесь на меня в Twitter и Medium.