Как работает компилятор Angular

Оригинальная статья в Angular Blog Алекса Рикабо здесь:

Angular Compiler (который мы называем ngc) — это инструмент, используемый для компиляции приложений и библиотек Angular. ngc основан на компиляторе TypeScript (называемом tsc) и расширяет процесс компиляции кода TypeScript для добавления дополнительной генерации кода, связанной с возможностями Angular.

Компилятор Angular служит мостом между опытом разработчика и производительностью во время выполнения. Пользователи Angular создают приложения с помощью удобного API на основе декораторов, а ngc переводит этот код в более эффективные инструкции во время выполнения.

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

После компиляции через ngc этот компонент выглядит следующим образом:

Декоратор @Component был заменен несколькими статическими свойствами (ɵfac и ɵcmp), которые описывают этот компонент во время выполнения Angular и реализуют рендеринг и обнаружение изменений для его шаблона.

Таким образом, ngc можно рассматривать как расширенный компилятор TypeScript, который также умеет «запускать» декораторы Angular, применяя их эффекты к декорированным классам во время компиляции (в отличие от времени выполнения).

Внутри ngc

ngc преследует несколько важных целей:

  • Компилируйте декораторы Angular, включая компоненты и их шаблоны.

  • Применение правил проверки типов TypeScript к шаблонам компонентов.

  • Быстрая перекомпиляция при внесении изменений разработчиком.

Давайте рассмотрим, как ngc справляется с каждой из этих целей.

Поток компиляции

Основной целью ngc является компиляция кода TypeScript при преобразовании распознаваемых в Angular декорированных классов в более эффективные представления во время выполнения. Основной поток компиляции Angular происходит следующим образом:

  1. Создайте экземпляр компилятора TypeScript с некоторыми дополнительными функциональными возможностями Angular.

  2. Просканируйте каждый файл в проекте на наличие декорированных классов и создайте модель того, какие компоненты, директивы, трубы, NgModules и т.д. должны быть скомпилированы.

  3. Устанавливать связи между декорированными классами (например, какие директивы используются в шаблонах компонентов).

  4. Используйте TypeScript для проверки выражений в шаблонах компонентов.

  5. Скомпилируйте всю программу, включая генерацию дополнительного кода Angular для каждого декорированного класса.

Шаг 1: Создание программы на языке TypeScript

В компиляторе TypeScript компилируемая программа представлена экземпляром ts.Program. Этот экземпляр объединяет набор файлов для компиляции, записывает информацию о зависимостях и конкретный набор опций компилятора, которые будут использоваться.

Определение набора файлов и зависимостей не является простым. Часто пользователь указывает файл «точки входа» (например, main.ts), и TypeScript должен просмотреть импорты в этом файле, чтобы обнаружить другие файлы, которые необходимо скомпилировать. Эти файлы имеют дополнительные импорты, которые расширяются на большее количество файлов, и так далее. Некоторые из этих импортов указывают на зависимости: ссылки на код, который не компилируется, но используется каким-то образом и должен быть известен системе типов TypeScript. Эти импорты зависимостей предназначены для файлов .d.ts, обычно в node_modules

На этом этапе компилятор Angular делает нечто особенное: он добавляет дополнительные входные файлы в ts.Program. Для каждого файла, написанного пользователем (например, my.component.ts), ngc добавляет «теневой» файл с суффиксом .ngtypecheck (например, my.component.ngtypecheck.ts). Эти файлы используются для внутренней проверки типа шаблона (подробнее об этом позже).

В зависимости от опций компилятора, ngcможет добавлять другие файлы в ts.Program, например, файлы .ngfactory для обратной совместимости с архитектурой View Engine.

Шаг 2: Индивидуальный анализ

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

Частичная оценка

Иногда информация в декораторе Angular скрыта за выражением. Например, селектор для компонента предоставляется в виде строкового литерала, но он также может быть константой:

ngc использует API TypeScript для навигации по коду, чтобы оценить выражение MY_SELECTOR, отследить его до объявления и, наконец, преобразовать его в строку 'my-cmp'. Частичный оценщик может понимать простые константы; литеральные объекты и массивы; доступ к свойствам; импорт/экспорт; арифметические и другие двоичные операции; и даже оценивать простые вызовы функций. Эта возможность дает разработчикам Angular больше гибкости в том, как они описывают компоненты Angular и другие типы Angular компилятору.

Результат анализа

В конце фазы анализа компилятор уже имеет представление о том, какие компоненты, директивы, трубы, инжекты и NgModules находятся во входной программе. Для каждого из них компилятор создает объект «метаданные», который описывает все, что он узнал из декораторов классов. В этот момент компоненты имеют свои шаблоны и таблицы стилей, загруженные с диска (если необходимо), и компилятор, возможно, уже выдал ошибки (известные в TypeScript как «диагностика»), если семантические ошибки были обнаружены где-либо во входных данных на данный момент.

Шаг 3: Глобальный анализ

Прежде чем проверять типы или генерировать код, компилятор должен понять, как различные оформленные типы в программе соотносятся друг с другом. Основная цель этого шага — понять структуру NgModule программы.

NgModules

Для проверки типов и генерации кода компилятору необходимо знать, какие директивы, компоненты и трубы используются в шаблоне для каждого компонента. Это не так просто, поскольку компоненты Angular не импортируют свои зависимости напрямую. Вместо этого компоненты Angular описывают шаблоны с помощью HTML, а потенциальные зависимости сопоставляются с элементами в этих шаблонах с помощью селекторов стилей CSS. Это позволяет создать мощный уровень абстракции: компонентам Angular не нужно знать, как именно структурированы их зависимости. Вместо этого, каждый компонент имеет набор потенциальных зависимостей (его «область компиляции шаблона»), только подмножество которых в конечном итоге будет соответствовать элементам в его шаблоне.

Эта непрямая связь решается с помощью абстракции Angular @NgModule. NgModules можно рассматривать как составные единицы области применения шаблонов. Базовый модуль NgModule может выглядеть следующим образом:

NgModules можно понимать так, что каждый из них декларирует две различные области действия:

  • Область компиляции», которая представляет собой набор потенциальных зависимостей, доступных для любого компонента, объявленного в самом NgModule.

  • Область экспорта», которая представляет собой набор потенциальных зависимостей, доступных в области компиляции любых NgModules, которые импортируют данный NgModule.

В приведенном примере ImageViewerComponent является компонентом, объявленным в этом NgModule, поэтому его потенциальные зависимости задаются областью компиляции NgModule. Эта область компиляции является объединением всех деклараций и областей экспорта всех импортируемых NgModules. Поэтому объявление компонента в нескольких NgModules является ошибкой в Angular. Кроме того, компонент и его NgModule должны быть скомпилированы одновременно.

В данном случае импортируется CommonModule, поэтому область компиляции ImageViewerModule (а значит, ImageViewerComponent) включает все директивы и конвейеры, экспортируемые CommonModuleNgIf, NgForOf, AsyncPipe, и полдюжины других. В область компиляции также входят обе объявленные директивы — ImageViewerComponent и ImageResizeDirective.

Обратите внимание, что для компонентов их связь с объявляющим их NgModule является двунаправленной: NgModule определяет область шаблонов компонента и делает этот компонент доступным в областях шаблонов других компонентов.

Приведенный выше NgModule также объявляет «область экспорта», состоящую только из ImageViewerComponent. Другие NgModules, которые импортируют этот модуль, будут иметь ImageViewerComponent, добавленный в их области компиляции. Таким образом, NgModule позволяет инкапсулировать детали реализации ImageViewerComponent — внутренне он мог бы использовать ImageResizeDirective, но эта директива недоступна потребителям ImageViewerComponent.

Чтобы определить эти области, компилятор создает граф NgModules, их объявления, а также их импорт и экспорт, используя информацию, которую он узнал о каждом классе в отдельности на предыдущем этапе. Также требуется знание о зависимостях: компонентах и NgModules, импортированных из библиотек и не объявленных в текущей программе. Angular кодирует эту информацию в файлах .d.ts этих зависимостей.

Метаданные .d.ts

Например, ImageViewerModule выше импортирует CommonModule из пакета @angular/common. Частичная оценка списка импорта разрешит классы, названные в объявлениях импорта в файлах .d.ts этих зависимостей.

Простого знания символа импортируемых NgModules недостаточно. Для построения своего графа компилятор передает информацию о декларациях, импортах и экспортах NgModules через файлы .d.ts в специальном типе метаданных. Например, в файле декларации, сгенерированном для модуля CommonModule Angular, эти (упрощенные) метаданные выглядят следующим образом:

Это объявление типа не предназначено для проверки типов TypeScript, а скорее включает информацию (ссылки и другие метаданные) о понимании Angular рассматриваемого класса в системе типов. Из этих специальных типов ngc может определить область экспорта CommonModule. Используя API TypeScript для разрешения ссылок в этих метаданных на определения классов, вы можете извлечь полезные метаданные о директивах.

Это дает ngc достаточно информации о структуре программы, чтобы продолжить компиляцию.

Шаг 4: Проверка типа шаблона

ngc может сообщать об ошибках типа в шаблонах Angular. Например, если шаблон пытается связать значение {{name.first}}, но объект name не имеет свойства first, ngc может отобразить эту проблему как ошибку типа. Эффективное выполнение этой проверки является серьезной проблемой для ngc.

Сам TypeScript не понимает синтаксис шаблонов Angular и не может проверить тип напрямую. Для выполнения этой проверки компилятор Angular преобразует шаблоны Angular в код TypeScript (известный как «блок проверки типов» или TCB), который выражает эквивалентные операции на уровне типов, и передает этот код в TypeScript для семантической проверки. Любая сгенерированная диагностика сопоставляется и сообщается пользователю в контексте исходного шаблона.

Например, рассмотрим компонент с шаблоном, который использует ngFor:

Для этого шаблона компилятор хочет проверить, что доступ к свойству user.name является легальным. Для этого он должен сначала понять, как тип user получается из переменной цикла через NgFor из входного массива users.

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

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

  • Сначала он выводит фактический тип директивы NgForOf (которая является общей) из ее входных привязок. Это называется _t1.

  • Он проверяет, что свойство users компонента может быть сопоставлено с записью NgForOf через оператор сопоставления _t1.ngForOf = ctx.users.

  • Затем он объявляет тип для встроенного контекста представления шаблона строки *ngFor, называемый _t2, с начальным типом any.

  • Используя if с вызовом защиты типа, используйте вспомогательную функцию ngTemplateContextGuard из NgForOf для ограничения типа _t2 в соответствии с тем, как работает NgForOf.

  • Неявная переменная цикла (user в шаблоне) извлекается из этого контекста и ей присваивается имя _t3.

  • Наконец, выражается доступ _t3.name.

Если доступ _t3.name не является легальным в соответствии с правилами TypeScript, TypeScript выдаст диагностическую ошибку для этого кода. Программа проверки типа шаблона Angular может увидеть местоположение этой ошибки в TCB и использовать встроенные комментарии для сопоставления ошибки с исходным шаблоном, прежде чем показать его разработчику.

Поскольку шаблоны Angular содержат ссылки на свойства классов компонентов, они имеют типы из программы пользователя. Поэтому код проверки типа шаблона не может быть проверен самостоятельно и должен проверяться в контексте всей программы пользователя (в приведенном выше примере тип компонента импортируется из файла test.ts пользователя). ngc достигает этого путем добавления сгенерированных TCB в пользовательскую программу через инкрементный шаг компиляции TypeScript (генерация нового ts.Program). Чтобы избежать гиперраспространения кэша инкрементальной компиляции, код проверки типов добавляется в отдельные файлы .ngtypecheck.ts, которые компилятор добавляет в ts.Program при создании, а не непосредственно в пользовательские файлы.

Шаг 5: Выпуск

Когда начинается этот этап, ngc понял программу и убедился в отсутствии фатальных ошибок. Затем компилятору TypeScript сообщается о необходимости сгенерировать код JavaScript для программы. В процессе генерации декораторы Angular удаляются, а вместо них в классы добавляются различные статические поля, и сгенерированный код Angular готов к написанию на JavaScript.

Если компилируемая программа является библиотекой, то также создаются файлы .d.ts. Файлы содержат встроенные метаданные Angular, описывающие, как будущая компиляция может использовать эти типы в качестве зависимостей.

Быть инкрементально быстрым

Если все вышесказанное звучит как большой объем работы перед генерацией кода, это потому, что так оно и есть. Хотя логика TypeScript и Angular эффективна, все равно может потребоваться несколько секунд для выполнения всех операций разбора, анализа и синтеза, необходимых для создания вывода JavaScript для входной программы. По этой причине и TypeScript, и Angular поддерживают режим инкрементальной компиляции, в котором ранее выполненная работа используется повторно для более эффективного обновления скомпилированной программы при внесении небольших изменений во входные данные.
Основная проблема инкрементальной компиляции заключается в следующем: при определенном изменении входного файла компилятору необходимо определить, какие выходные данные могли измениться и какие выходные данные безопасны для повторного использования. Компилятор должен быть совершенным и не перекомпилировать вывод, если он не может быть уверен, что он не изменился.
Для решения этой проблемы компилятор Angular имеет два основных инструмента: граф импорта и семантический граф зависимостей.

Импортный график

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

Например, если файл my.component.ts содержит компонент, а селектор этого компонента определяется константой, импортированной из selector.ts, график импорта показывает, что my.component.ts зависит от selector.ts. Если selector.ts изменится, компилятор может запросить этот граф и узнать, что результаты разбора my.component.ts больше не верны и должны быть переделаны.

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

  • Он слишком чувствителен к несвязанным изменениям. Если selector.ts изменен, но это изменение только добавляет комментарий, то my.component.ts на самом деле не нуждается в перекомпиляции.

  • Не все зависимости в приложениях Angular выражаются через импорт. Если селектор MyCmp изменится, другие компоненты, использующие MyCmp в своем шаблоне, могут быть затронуты, даже если они никогда не импортируют MyCmp напрямую.

Обе эти проблемы решаются вторым инструментом инкрементализации компилятора:

Семантический граф зависимостей 

Семантический граф зависимостей начинается там, где заканчивается граф импорта. Этот граф отражает фактическую семантику компиляции: как компоненты и директивы соотносятся друг с другом. Его задача — выяснить, какие семантические изменения потребуют воспроизведения определенного вывода.

Например, если selector.ts будет изменен, но селектор для MyCmp не изменится, то граф семантической глубины будет знать, что ничего, что семантически влияет на MyCmp, не изменилось, и предыдущий вывод MyCmp может быть использован повторно. И наоборот, если селектор изменится, то набор компонентов/директив, используемых в других компонентах, может измениться, и семантический граф будет знать, что эти компоненты необходимо перекомпилировать.

Инкрементальность

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

Резюме

Компилятор Angular использует преимущества гибкости API компилятора TypeScript для обеспечения корректной и эффективной компиляции шаблонов и классов Angular. Компиляция приложений Angular позволяет нам обеспечить желаемый опыт разработчика в IDE, обеспечить обратную связь по проблемам кода во время компиляции и преобразовать этот код в процессе компиляции в наиболее эффективный JavaScript для запуска в браузере.

Оцените статью
Procodings.ru
Добавить комментарий