[Перевод] Angular 9 и Ivy: ленивая загрузка компонентов

Ленивая загрузка компонентов в Angular? Может, речь идёт о ленивой загрузке модулей с помощью маршрутизатора Angular? Нет, мы говорим именно о компонентах. Текущая версия Angular поддерживает лишь ленивую загрузку модулей. Но Ivy даёт разработчику новые возможности в работе с компонентами.

ox8dubshhrdlijoson4_gyjtarw.png

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


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

const routes: Routes = [
    { path: 'customer-list',
      loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule) }
    ];


Благодаря вышеприведённому коду будет создан отдельный фрагмент для customers.module, который будет загружен тогда, когда пользователь перейдёт по маршруту customer-list.

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

Но, несмотря на это, разве не здорово было бы получить возможность более точного управления ленивой загрузкой? Например, что если бы мы могли организовать ленивую загрузку отдельных компонентов?

До сих пор сделать этого было нельзя. Но всё изменилось с приходом Ivy.

Ivy и концепция «локальности»


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

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

Ivy, с другой стороны, использует другой подход. В Ivy компонент может существовать без модуля. Это возможно благодаря концепции локальности (Locality). Её суть заключается в том, что все метаданные являются локальными для компонента.

Поясним это, проанализировав es2015-бандл, сгенерированный с использованием Ivy.

02b7753703a0599491c3f9076527168f.png


Es2015-бандл, сгенерированный с использованием Ivy

В разделе Component code (Код компонента) можно видеть, что система Ivy сохранила код компонента. Тут нет ничего особенного. Но затем Ivy добавляет к коду некоторые метаданные.

Первый фрагмент метаданных представлен на рисунке как Component factory (Фабрика компонента). Фабрика знает о том, как создавать экземпляры компонента. В разделе Component metadata (Метаданные компонента) Ivy размещает дополнительные атрибуты, наподобие type и selectors, то есть — всё, что нужно компоненту во время выполнения программы.

Достойно отдельного упоминания то, что Ivy добавляет сюда и функцию template. Она показана в разделе Compiled version of your template (Скомпилированная версия шаблона). Остановимся на этом интересном факте подробнее.

Функция template — это скомпилированная версия нашего HTML-кода. Она выполняет инструкции Ivy для создания DOM. Это отличается от того, как работает ViewEngine.

Система ViewEngine берёт код и обходит его. Затем Angular выполнит код в том случае, если мы им воспользуемся.

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

Реальный пример ленивой загрузки компонента


Теперь, когда мы знаем о том, что ленивая загрузка компонентов возможна, рассмотрим её на реальном примере. А именно — создадим приложение Quiz, которое задаёт пользователю вопросы с вариантами ответов.

Приложение выводит изображение города и варианты, из которых нужно выбрать название этого города. Как только пользователь выберет вариант, нажав на соответствующую кнопку, эта кнопка немедленно изменится, указывая на то, правильным или неправильным был ответ. Если фон кнопки станет зелёным — значит ответ был правильным. Если фон станет красным — значит ответ был неправильным.

После того, как получен ответ на текущий вопрос, программа показывает следующий вопрос. Вот как это выглядит.

2fc768ecebd5c52399030c0d2dc47c66.gif


Демонстрация работы приложения Quiz

Вопросы, которые программа задаёт пользователю, представлены компонентом QuizCardComponent.

Концепция ленивой загрузки компонента


Давайте сначала проиллюстрируем общую идею ленивой загрузки компонента QuizCardComponent.

4ab45ab2b677ce0d886d2f26a5645edb.png


Процесс работы с компонентом QuizCardComponent

После того, как пользователь запускает викторину, нажав на кнопку Start quiz, мы начинаем ленивую загрузку компонента. После того, как компонент оказывается в нашем распоряжении, мы помещаем его в контейнер.

Мы реагируем на события questionAnsvered «ленивого» компонента так же, как реагировали бы на события обычного компонента. А именно, после возникновения события мы выводим на экране очередную карточку с вопросом.

Анализ кода


Для того чтобы разобраться в том, что происходит в ходе ленивой загрузки компонента, начнём с упрощённой версии QuizCardComponent, которая выводит свойства вопроса.

Затем мы расширим этот компонент, использовав в нём компоненты Angular Material. А в итоге — настроим реакцию на события, генерируемые компонентом.

Давайте организуем ленивую загрузку упрощённой версии компонента QuizCardComponent, которая имеет следующий шаблон:

Here's the question

        
  • Image: {{ question.image }}
  •     
  • Possible selections: {{ question.possibleSelections.toString() }}
  •     
  • Correct answer: {{ question.correctAnswer }}


Первый шаг работы заключается в создании элемента-контейнера. Для этого мы можем либо воспользоваться реальным элементом, вроде

, либо — прибегнуть к ng-container, что позволит обойтись без дополнительного уровня HTML-кода. Вот как выглядит объявление элемента-контейнера, в который мы поместим «ленивый» компонент:


  City quiz





В компоненте нужно получить доступ к контейнеру. Для этого мы используем аннотацию @ViewChild и сообщим ей, что хотим прочесть ViewContainerRef.

Обратите внимание на то, что в Angular 9 свойство static в аннотации @ViewChild по умолчанию установлено в false:

@ViewChild('quizContainer', {read: ViewContainerRef}) quizContainer: ViewContainerRef;


Теперь у нас есть контейнер, в который мы хотим добавить «ленивый» компонент. Далее, нам нужны ComponentFactoryResolver и Injector. И тем и другим можно обзавестись, прибегнув к методике внедрения зависимостей.

Сущность ComponentFactoryResolver — это простой реестр, устанавливающий соотношения компонентов и автоматически генерируемых классов ComponentFactory, которые могут быть использованы для создания экземпляров компонентов:

constructor(private quizservice: QuizService,
            private cfr: ComponentFactoryResolver,
            private injector: Injector) {
}


Теперь в нашем распоряжении имеется всё, что нужно для достижения цели. Поработаем над содержимым метода startQuiz и организуем ленивую загрузку компонента:

const {QuizCardComponent} = await import('./quiz-card/quiz-card.component');
const quizCardFactory = this.cfr.resolveComponentFactory(QuizCardComponent);
const {instance} = this.quizContainer.createComponent(quizCardFactory, null, this.injector);
instance.question = this.quizservice.getNextQuestion();


Мы можем воспользоваться ECMAScript-командой import для организации ленивой загрузки QuizCardComponent. Выражение импорта возвращает промис. Работать с ним можно, либо воспользовавшись конструкцией async/await, либо — с помощью обработчика .then. После разрешения промиса мы, для создания экземпляра компонента, применяем деструктурирование.

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

Фабрика ComponentFactory даёт нам componentRef. Мы передаём componentRef и Injector методу createComponent контейнера.

Метод createComponent возвращает ComponentRef, где содержится экземпляр компонента. Мы используем instance для передачи компоненту @Input-свойств.

В будущем всё это, вероятно, можно будет сделать с помощью метода Angular renderComponent. Это метод всё ещё носит статус экспериментального. Однако весьма вероятно то, что он превратится в обычный метод Angular. Вот полезные материалы на эту тему.

Это — всё, что нужно для организации ленивой загрузки компонента.

2d42bd4bc38ee01c427c94286e060cbb.png


Ленивая загрузка компонента

После того, как будет нажата кнопка Start quiz, начинается ленивая загрузка компонента. Если открыть вкладку Network инструментов разработчика, можно увидеть процесс ленивой загрузки фрагмента кода, представленного файлом quiz-card-quiz-card-component.js. После загрузки и обработки компонент выводится на экран, пользователь видит карточку вопроса.

Расширение компонента


Сейчас мы загружаем компонент QuizCardComponent. Это очень хорошо. Но наше приложение пока не особенно функционально.

Исправим это, добавив в него дополнительные возможности и компоненты Angular Material:


  
    
    Which city is this?     Click on the correct answer below   
  Photo of a Shiba Inu           


Мы включили в компонент несколько красивых компонентов Material. А как насчёт соответствующих модулей?

Их, конечно, можно добавить к AppModule. Но это означает, что эти модули будут загружаться в «жадном» режиме. А это — не лучшая идея. Более того, сборка проекта завершится неудачно, с выдачей следующего сообщения:

ERROR in src/app/quiz-card/quiz-card.component.html:9:1 - error TS-998001: 'mat-card' is not a known element:
1. If 'mat-card' is an Angular component, then verify that it is part of this module.
2. If 'mat-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.


Что делать? Как вы уже, наверное, поняли, данная проблема вполне решаема. Сделать это можно с помощью модулей.

Но в этот раз мы воспользуемся ими немного не так, как раньше. Мы добавим маленький модуль в тот же файл, в котором находится QuizCardComponent (в файл quizcard.component.ts):

@NgModule({
  declarations: [QuizCardComponent],
  imports: [CommonModule, MatCardModule, MatButtonModule]
})
class QuizCardModule {
}


Обратите внимание на то, что тут нет выражения export.

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

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

Перезапустим приложение и посмотрим на то, как оно ведёт себя при нажатии на кнопку Start quiz.

bc54bfc6057cc6707e4f01a5bf871de9.png


Анализ доработанного приложения

Замечательно! Компонент QuizCardComponent загружается в ленивом режиме и добавляется в ViewContainer. Вместе с ним подгружаются и все необходимые зависимости.

Проанализируем бандл приложения с помощь инструмента webpack-bundle-analyze, представленного соответствующим npm-модулем. Он позволяет визуализировать содержимое файлов, которые выдаёт Webpack, и исследовать полученную схему.

d2aa5a2aae380755aca3872db6d068f9.png


Анализ бандла приложения

Размер основного бандла приложения — примерно 260 Кб. Если бы мы загрузили вместе с ним и компонент QuizCardComponent, то размер загружаемых данных составил бы уже примерно 270 Кб. Получается, что нам удалось уменьшить размер основного бандла на 10 Кб, загрузив в ленивом режиме лишь один компонент. Неплохой результат!

Код QuizCardComponent после сборки попадает в отдельный файл. Если проанализировать содержимое этого файла, то окажется, что там находится не только код QuizCardComponent, но и модули Material, использованные в этом компоненте.

Даже хотя QuizCardComponent использует MatButtonModule и MatCardModule, в готовый файл с кодом попадает только MatCardModule. Причина этого заключается в том, что мы используем MatButtonModule и в AppModule, выводя кнопку Start quiz. В результате соответствующий код попадает в другой фрагмент бандла.

Сейчас мы организовали ленивую загрузку QuizCardComponent. Этот компонент выводит карточку, оформленную в стиле Material, содержащую картинку, вопрос и кнопки с вариантами ответа. Что сейчас происходит в том случае, если нажать на одну из таких кнопок?

Кнопка, в зависимости от того, правильный или неправильный ответ она содержит, становится при нажатии зелёной или красной. А что ещё происходит? Ничего. А нам надо, чтобы после ответа на один вопрос, выводилась бы карточка ещё одного вопроса. Исправим это.

Обработка событий


Приложение не показывает карточку нового вопроса при нажатии на кнопку ответа из-за того, что мы пока не настроили механизмы реагирования на события компонентов, загружаемых в ленивом режиме. Мы уже знаем о том, что компонент QuizCardComponent генерирует события, используя EventEmitter. Если посмотреть на определение класса EventEmitter, можно узнать о том, что он является наследником Subject:

export declare class EventEmitter extends Subject


Это значит, что у EventEmitter есть метод subscribe, который и позволяет настроить реакцию системы на возникающие события:

instance.questionAnswered.pipe(
    takeUntil(instance.destroy$)
).subscribe(() => this.showNewQuestion());


Тут мы подписываемся на поток questionAnswered и вызываем метод showNextQuestion, выполняющий логику lazyLoadQuizCard:

async showNewQuestion() {
  this.lazyLoadQuizCard();
}


Конструкция takeUntil(instance.destroy$) необходима для очистки подписки, выполняемой после уничтожения компонента. Если вызывается хук ngOnDestroy жизненного цикла компонента QuizCardComponent, то вызывается destroy$ с next и complete.

Так как компонент уже загружен — система не выполняет дополнительного HTTP-запроса. Мы пользуемся содержимым фрагмента кода, который уже у нас есть, создаём новый компонент и помещаем его в контейнер.

Хуки жизненного цикла компонента


Почти все хуки жизненного цикла компонента автоматически вызываются при работе с компонентом QuizCardComponent с использованием методики ленивой загрузки. Но одного хука не хватает. Можете понять — какого?

ceb4f7b2a34bbed6ced31f69713e4f68.png


Хуки жизненного цикла компонента

Это — важнейший хук ngOnChanges. Так как мы самостоятельно обновляем входные свойства экземпляра компонента, мы ответственны и за вызов хука жизненного цикла ngOnChanges.

Для вызова хука ngOnChanges экземпляра компонента нужно самостоятельно сконструировать объект SimpleChange:

(instance as any).ngOnChanges({
    question: new SimpleChange(null, instance.question, true)
});


Мы вручную вызываем ngOnChanges экземпляра компонента и передаём ему объект SimpleChange. Этот объект указывает на то, что данное изменение является первым, что предыдущее значение равняется null, и что текущее значение — это вопрос.

Замечательно! Мы загрузили компонент со сторонними модулями, отреагировали на генерируемые им события и наладили вызов нужных хуков жизненного цикла компонента.

Вот исходный код готового проекта, над котором мы тут работали.

Итоги


Ленивая загрузка компонентов даёт Angular-разработчику замечательные возможности по оптимизации производительности приложений. В его распоряжении оказываются инструменты для очень точной настройки состава материалов, загружаемых в ленивом режиме. Раньше, когда можно было загружать в ленивом режиме лишь маршруты, такой точности у нас не было.

К сожалению, при использовании в компоненте сторонних модулей, нам нужно заботиться ещё и о модулях. Однако стоит помнить о том, что в будущем это может измениться.

Движок Ivy ввёл концепцию локальности, благодаря которой компоненты могут существовать самостоятельно. Это изменение является основой будущего Angular.

Уважаемые читатели! Планируете ли вы использовать методику ленивой загрузки компонентов в своих Angular-проектах?

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru