От автора: при переходе с многостраничного сайта на одностраничный одна из проблем заключается в размере скачиваемых файлов при первичной загрузке. По умолчанию в приложении Angular компоненты запакованы в одну полезную нагрузку, т.е. по мере роста приложения будет расти и время загрузки. Обычный способ решения – использовать роутер для ленивой загрузки модулей, как сказано в документации к Angular. Такой способ работает, когда у контента есть роут, но что будет, если компоненты не входят в отдельный роут? В этой статье мы покажем вам, как с помощью Angular CLI разбить компоненты на отдельный пакеты, что позволит загружать их по необходимости.
Обманываем Angular CLI
Angular CLI, в частности пакет @ngtools/webpack, выполняет статический анализ приложения в момент сборки для поиска роут путей с ленивой загрузкой. Анализируются все роут пути, поэтому Webpack создает часть, которую можно потом загрузить, когда роут станет активен. Внутри этой части есть ModuleFactory для заданного роута.
Так сложилось, что возможность разделения без использования роутера – сложная задача. Ее попросили реализовать в новой версии. Однако мы можем использовать статический анализ и обмануть Angular CLI, чтобы он разбил наши модули компонентов, что позволяет динамически загружать компоненты.
Давайте узнаем, как это сделать через создание динамического MessageComponent!
Создание первого динамического компонента
Сперва создадим папку для компонентов dynamic-modules. В папке создайте еще одну папку message для MessageComponent. Скопируйте в этот файл следующий код:
import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-message', template: 'Hello World', }) export class MessageComponent implements OnInit { constructor() { } ngOnInit() { } }
Теперь необходимо создать модуль, объявляющий этот компонент. Назовем его MessageModule.
import { NgModule } from '@angular/core'; import { DYNAMIC_COMPONENT } from '../../dynamic-component-loader/dynamic-component-manifest'; import { MessageComponent } from './message.component'; @NgModule({ declarations: [ MessageComponent, ], imports: [ ], providers: [ ], entryComponents: [ MessageComponent, ], }) export class MessageModule {}
Для генерации ComponentFactory компонент должен быть также добавлен в entryComponents модуля.
Манифест динамического компонента
Поможем обработке динамических компонентов, создадим простой манифест интерфейс, напоминающий интерфейс Route в @angular/router.
export interface DynamicComponentManifest { componentId: string; path: string; loadChildren: string; }
Идея простая – будем добавлять новую манифест запись для каждого компонента, который необходимо загружать динамически.
Очень важно, чтобы этот интерфейс был похож на тип Route. Когда запускается статический анализ в процессе сборки и после анализа токена ROUTES, @ngtools/webpack создаст фабрики для всех путей с настроенным свойством loadChildren. Обратите внимание, что свойство path может быть любой уникальной строкой, пока оно не конфликтует с существующими роутами в приложении. Для уникального представления компонента, который необходимо загрузить, добавляется componentId.
Загрузчик динамических компонентов
Нам необходим модуль, который будет загружать манифесты (для статического анализа в компиляторе) и искать и получать объекты ComponentFactory.
Начнем с создания новой папки dynamic-component-loader, в которой будет храниться наш код. В этой папке создадим новый модуль DynamicComponentLoaderModule.
Наш модуль содержит функцию forRoot(), которая принимает массив DynamicComponentManifest. Теперь всем, кто использует модуль, необходимо предоставлять список манифестов во время первичной загрузки приложения.
Теперь нужно обмануть CLI с парсингом массива в рамках статического анализа. Мы можем предоставить передаваемый массив DynamicComponentManifest мультипровайдеру ROUTES.
@NgModule({ providers: [] }) export class DynamicComponentLoaderModule { static forRoot(manifests: DynamicComponentManifest[]): ModuleWithProviders { return { ngModule: DynamicComponentLoaderModule, providers: [ // provider for Angular CLI to analyze { provide: ROUTES, useValue: manifests, multi: true } ], }; } }
Создание фабрики с поиском
Теперь необходимо реализовать сервис, который сможет обнаруживать и получать скомпилированные объекты ComponentFactoryobjects.
Наш сервис будет использовать SystemJsNgModuleLoader, поэтому добавьте его в список провайдеров модуля:
providers: [ { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader }]
Для упрощения создадим новый InjectionToken, чтобы наше приложение могли читать манифесты. Создайте токен:
export const DYNAMIC_COMPONENT_MANIFESTS = new InjectionToken<any>(‘DYNAMIC_COMPONENT_MANIFESTS’);
Затем обновите DynamicComponentLoaderModule и добавьте в него новый провайдер, ссылающийся на токен к манифесту.
@NgModule({ providers: [ { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader }, ], }) export class DynamicComponentLoaderModule { static forRoot(manifests: DynamicComponentManifest[]): ModuleWithProviders { return { ngModule: DynamicComponentLoaderModule, providers: [ { provide: ROUTES, useValue: manifests, multi: true }, { provide: DYNAMIC_COMPONENT_MANIFESTS, useValue: manifests }, ], }; } }
Теперь можно создать наш сервис DynamicComponentLoader и объявить его в нашем модуле. В конструктор сервиса необходимо вставить токен DYNAMIC_COMPONENT_MANIFESTS и NgModuleFactoryLoader.
@Injectable() export class DynamicComponentLoader { constructor( @Inject(DYNAMIC_COMPONENT_MANIFESTS) private manifests: DynamicComponentManifest[], private loader: NgModuleFactoryLoader ) { } }
Далее создайте публичный метод getComponentFactory.
Этот метод будет по componentId искать подходящий модуль компонента в массиве манифестов, загружать модуль через NgModuleFactoryLoader и создавать новый объект модуля.
getComponentFactory<T>(componentId: string, injector?: Injector): Observable<ComponentFactory<T>> { const manifest = this.manifests .find(m => m.componentId === componentId); const p = this.loader.load(manifest.loadChildren) .then(ngModuleFactory => { const moduleRef = ngModuleFactory.create(injector || this.injector); // Problem! How do we get at the component this module provides? }); return ObservableFromPromise(p); }
Но есть одна проблема. У нас есть объект moduleRef с нужным нам ComponentFactory, но мы можем резолвить Component только по типу.
Для обнаружения нужной фабрики компонента нам понадобятся модули динамических компонентов для определения компонента по умолчанию, который необходимо создать. То есть мы создаем соглашение: каждый модуль должен определять токен, представляющий тип динамического компонента. Таким образом, когда мы резолвим модуль, мы можем с помощью Injector найти этот токен, а значит, и подходящий тип компонента.
Начнем с создания InjectionToken:
export const DYNAMIC_COMPONENT = new InjectionToken<any>(‘DYNAMIC_COMPONENT’);
Нужно вернуться к MessageModule и предоставить этот токен в MessageComponent.
@NgModule({ declarations: [ MessageComponent, ], providers: [ { provide: DYNAMIC_COMPONENT, useValue: MessageComponent }, ], entryComponents: [ MessageComponent, ], }) export class MessageModule {}
Теперь необходимо обновить метод getComponentFactory сервиса DynamicComponentLoader под использование инъектора moduleRef для поиска токена.
После обнаружения токена можно вызвать ComponentFactoryResolver на moduleRef для поиска подходящего ComponentFactory.
getComponentFactory<T>(componentId: string, injector?: Injector): Observable<ComponentFactory<T>> { const manifest = this.manifests .find(m => m.componentId === componentId); const p = this.loader.load(manifest.loadChildren) .then(ngModuleFactory => { const moduleRef = ngModuleFactory.create(injector || this.injector); // Read from the moduleRef injector and locate the dynamic component type const dynamicComponentType = moduleRef.injector.get(DYNAMIC_COMPONENT); // Resolve this component factory return moduleRef.componentFactoryResolver.resolveComponentFactory<T>(dynamicComponentType); }); return fromPromise(p); }
Предоставление манифеста
После завершения DynamicComponentModule можно создать манифест в AppModule. Откройте app.module.ts и создайте новый объект manifests.
const manifests: DynamicComponentManifest[] = [ { componentId: 'message', path: 'dynamic-message', loadChildren: './dynamic-modules/message/message.module#MessageModule', }, ];
Мы передали нашему компоненту componentId «message». Это ключ, с помощью которого будет возвращаться найденный ComponentFactory. Свойство loadChildren указывает на относительный путь модуля, как роут с ленивой загрузкой. Свойство path может быть чем угодно, пока оно не мешает другим роутам.
Включите объект манифестов в AppModule в вызове forRoot DynamicComponentLoaderModule.
@NgModule({ declarations: [ AppComponent, ], imports: [ BrowserModule, DynamicComponentLoaderModule.forRoot(manifests), ], providers: [], bootstrap: [ AppComponent, ], }) export class AppModule { }
Теперь мы можем лениво загружать этот компонент!
Вставка компонента в шаблон
На данный момент у нас есть сервис, который даст нам прямую ссылку на ComponentFactory и автоматически выполнит необходимую ленивую загрузку. Нам осталось лишь с помощью фабрики создать наш компонент любыми возможными средствами (ViewContainerRef, Angular CDK Portals и т.д.).
Давайте воспользуемся ViewContainerRef. Скопируйте следующий элемент в файл app.component.html:
<button type="button" (click)="loadComponent()">Load!</button> <div #testOutlet></div>
В файл app.component.ts добавьте следующее:
@Component({ selector: 'app-root', templateUrl: './app.component.html', }) export class AppComponent { @ViewChild('testOutlet', {read: ViewContainerRef}) testOutlet: ViewContainerRef; constructor( private dynamicComponentLoader: DynamicComponentLoader, ) { } loadComponent() { this.dynamicComponentLoader .getComponentFactory<MessageComponent>('message') .subscribe(componentFactory => { this.testOutlet.createComponent(componentFactory); }, error => { console.warn(error); }); } }
Запустите приложение и кликните на кнопку Load!. Загрузился не только компонент, но если проверить трафик сети, можно увидеть, что Webpack создал отдельную часть для этого компонента.
Зачем это нужно?
Мы узнали, как динамически загружать компонент. Теперь вы можете задаться вопросом: «зачем это нужно?». Это полезно в некоторых сценариях. Но прежде чем разобрать их, скажу, что динамическую стратегию не стоит применять ко всем компонентам. Затраченные усилия не стоят получаемых выгод. Тем не менее, при правильном использовании динамическая загрузка может дать хороший прирост производительности приложению.
Два самых очевидных случая использования – большие и редко используемые компоненты. В случае с большим весом компонента лучше позволить остальному приложению загружаться и сэкономить на загрузке компонента. Точно так же, если вы знаете, что 95% ваших пользователей не будут загружать определенные компоненты, то это первые кандидаты под динамическую загрузку, чтобы смягчить первичную загрузку.
Есть и другие менее очевидные случаи использования динамической загрузки контента. Один из них – ситуация, решенная pull запросом из топа этого PR для Angular.io. Angular.io имеет статичную HTML страницу с тегами, которые динамически изменяются при необходимости соответствующими компонентами AngularAngular. Преимущество здесь в том, что хотя страница и использует много компонентов Angular, первично ее можно загрузить с минимальным их количеством, после чего уже по необходимости добавлять полезную нагрузку.
На Angular.io много контента, но типичный пользователь в этой сессии не будет просматривать его весь. Уверен, есть и другие случаи использования. Если вы знаете про них, пишите в комментариях!
Рабочая демонстрация и код: https://github.com/devboosts/dynamic-component-loader
Автор: Chaz Gatian
Источник: https://blog.angularindepth.com/
Редакция: Команда webformyself.