От автора: как вы уже знаете, я люблю Angular, и всю ту магию, которую с его помощью можно творить. Я подумал, что будет интересно посмотреть на построение компилятора Angular 4, попробовать его переделать и сымитировать некоторые участки процесса компиляции.
Разбор компилятора был очень интересной задачей, большую часть того, что я узнал, я рассказал на конференции ng-conf 2017: DiY Angular Compiler. Мне очень нравится учиться и возиться с чем-нибудь, и я подумал, что было бы неплохо поделиться частью знаний в форме блога! Представляю вам «очень глубокое погружение в компилятор Angular».
Как и в большинстве моих постов, будет лучше, если вы будете следовать за мной, поэтому прежде чем перейти к сути, вам нужно установить пару вещей:
Во-первых, нужно установить node.js и npm. Также нужна последняя версия Angular CLI (версия 1.2.0 или выше). Проверить версию можно так:
1 |
ng -v |
Результат будет выглядеть примерно так:
1 |
@angular/cli: 1.2.0 |
Если версия старая, установите последнюю версию Angular CLI:
1 |
npm i -g @angular/cli |
Также нам понадобится еще один замечательный инструмент — source-map-explorer. Если у вас его нет, его можно установить:
1 |
npm i -g source-map-explorer |
Изоляция компилятора
Чтобы начать наше глубокое погружение в компилятор Angular, давайте создадим новый проект. Перейдите в любую подходящую директорию и введите:
1 |
ng new compiler-playground |
Процесс компиляции программы займет пару минут, после чего вы получите новый проект в папке compiler-playground. Перейдите в эту папку и введите:
1 |
ng build |
Команда создаст папку dist с скомпилированным приложением. Вы заметите, что конечные JS-файлы весят довольно много: если открыть папку dist, то там будет лежать файл vendor.bundle.js размером около 2Мб. Совсем не круто!
Если открыть этот файл, то внутри мы увидим неминифицированный код. На этот файл можно запустить uglify, что значительно сожмет его вес – примерно до 650Кб. Но это все равно много для простого приложения «Hello world».
Тут нам поможет source-map-explorer. С его помощью можно открывать сборку и смотреть, что делает ее такой большой. Введите следующую команду:
1 |
source-map-explorer dist/vendor.bundle.js |
Подождите пару секунд, после чего мы получим примерно следующую картину:
Можно заметить, что модуль compiler занимает около 50% общего размера сборки. Это около 1Мб (или 320Кб в минифицированном виде) данных, которые пользователи должны скачивать.
К счастью, очень просто избавиться от компилятора. Просто запустите:
1 |
ng build -prod --sourcemaps |
и часть, ответственная за компилятор, будет магическим образом удалена с помощью функции Angular AoT (“Ahead of Time”). AoT запускает шаг компиляции в момент создания билда, а не внутри браузера. Т.е. когда будете создавать билд проекта в продуктив, компилятор вообще может полностью исчезнуть, что сэкономит драгоценные циклы процессора при загрузке в браузере пользователя.
Теперь давайте взглянем на папку dist: вендорные JS-файлы сжались до 310Кб. С помощью source-map-explorer можно заметить, что большая часть, ответственная за компилятор исчезла:
Можно с легкостью избавиться еще от 30% размера, если удалить модули forms и http (если не будем их использовать). Надеюсь, в будущем система создания билда будет умнее и будет делать это за нас (для удаления неиспользуемого кода есть свой термин — tree-shaking). Если удалить forms и http (нам они не понадобятся) и включить сжатие, размер файл упадет до 79Кб.
Заметка: цифры могут слегка отличаться, все зависит от конкретной версии Angular.
Так что же компилятор Angular делает там? Как его можно удалить, и будет ли приложение работать после этого? Зачем он нужен на первом месте?
Чтобы понять роль компилятора, давайте посмотрим на внутреннюю работу Angular.
Внутри Angular: шаблоны и представления
При создании шаблонов мы задаем то, как должно выглядеть представление. В основном, мы с помощью языка HTML описываем структуру DOM и привязываем данные к ней. При старте приложения Angular должен создать DOM-дерево по вашему шаблону и заполнить его данными. Т.е. если вы напишите <h1>{{title}}</h1> в представлении, Angular должен будет выполнить код, похожий на этот (если ваш экземпляр контроллера компонента называется ctrl):
1 2 |
const h1Element = document.createElement('h1'); h1Element.innerText = ctrl.title; |
Кроме того, Angular обязан мониторить значение свойства title и обновлять элемент при изменении значения.
В AngularJS (версии до Angular или версии 1.х) создание DOM отдавалось браузеру, который парсил ваш HTML и создавал дерево DOM (все-таки это его работа), после чего AngularJS пробегался по элементам DOM, распознавал директивы и выражения привязки и заменял их реальными данными (код в AngularJS, отвечающий за это).
Такой подход выявил несколько проблем. Во-первых, браузеры разные. Разные браузеры парсят один и тот же HTML в разные структуры DOM (пример), и Angular должен учитывать это. Также браузеры не так хорошо работают с ошибками – они зачастую пытаются скрыть ошибку, автоматически закрывая элементы или перемещая их. Даже если ошибка и отображается на экране, то в ней не указывается номер строки. Это сильно усложняет дебаг и ведет к закрытию приложения до тех пор, пока мы не найдем ошибку.
Кроме того, это значит, что нам нужен браузер лишь для парсинга наших шаблонов и рендеринга их в HTML, который уже можно передать клиенту и мгновенно отобразить (и поисковым движкам), что делает серверный рендеринг сложным и подверженным ошибкам настройки (более подробно здесь или здесь).
Наконец, по каким-то причинам HTML зависим от регистра в названии HTML-тегов и атрибутов. Также он не сохраняет оригинальный регистр, конвертируя имена тегов в верхний регистр, а атрибутов в нижний. Посмотреть это можно, если запустить:
1 |
document.createElement('h1').nodeName |
На выходе вы получаете H1. Это заставило AngularJS использовать знаменитый змеиный регистр (например, ng-if, ng-model) вместо верблюжьего, который используется в JS.
Если использовать HTML-парсер браузера, мы получим разные результаты в разных браузерах, у нас не будет информации об ошибках, мы не сможем делать рендеринг на сервере, а также теряем регистр в атрибутах.
Вот зачем у нас есть компилятор. Компилятор заменяет браузер и парсит HTML. Это дает нам одинаковый результат во всех браузерах, а значит, он может выполняться на сервере (это просто кусок JS-кода, который парсит шаблоны), давать полную информацию об ошибках и сохранять регистр тегов/атрибутов. Также он дает нам парочку очень крутых инструментов, но о них чуть позже.
Компилятор Angular: производительность, производительность и еще раз производительность!
Компилятор Angular – удивительная инженерная мысль, что скоро мы и увидим. Он весит 1Мб не просто так – это результат больше года работы команды Angular. Он не просто парсит шаблоны, он также создает высокопроизводительный код и настроен на создание и обновление DOM с минимальными затратами процессора и памяти.
Цель добавления компилятора заключалась (и сейчас) в снижении нагрузки на память, быстрой загрузке страниц и быстром обнаружении изменений. Вот ссылка на исследования, выполненные перед внедрением компилятора в Angular 4: генерация Less кода.
Сейчас команда Angular усердно работает над улучшением инструментов и интеграции с компилятором Closure, инструментом, который применяет агрессивную оптимизацию к JS-коду, что еще сильнее сжимает размер билда и снижает время выполнения. Вот что мне нравится в Angular – за ним стоит замечательная команда, которая постоянно его улучшает, и наши приложения становятся быстрее и лучше, прям как хорошее вино. А плоды их тяжелой работы мы получаем бесплатно!
Теперь перейдем к компилятору!
Запуск компилятора
Вставьте эти строки в секцию scripts в файле package.json:
1 2 3 4 |
"scripts": { ..., "compile": "ngc" } |
И выполните:
1 |
npm run compile |
Подождите пару секунд, и вы увидите, что в папке проекта создалось много файлов. Ваш файл app.component.html превратился в app.component.ngfactory.ts, а app.module.ts теперь app.module.ngfactory.ts. CSS-файлы превратились в шимы. Ниже мы рассмотрим все файлы.
Компоненты (создание представления и определение изменений)
Компилятор Angular трансформирует наши 3 строки HTML в шаблоне в app.component.ngfactory.ts. Если открыть этот файл, то в нем можно увидеть много кода, который сложно понять сразу. Этот код написан для машин, а не людей – вот почему нам нужно немного терпения и навыков реверс инжиниринга. Тут нам поможет TypeScript.
Всего 3 строки HTML дали столько кода!
Первое, что бросается в глаза – множество непонятных методов, объявленных с помощью ɵ (греческая Тета), после которой идут 3 латинские символа (например, ɵvid). Буква ɵ используется командой Angular для индикации того, что некоторые методы имеют видимость private для фреймворка и не должны вызываться напрямую пользователем, так как API для этих методов не гарантирует стабильность между версиями Angular (на деле, я гарантирую, что почти всегда это не работает).
Почему используется 3 символа, а не полное название метода – экономия байтов в финальной сборке. Но если сделать Ctrl+click на этих методах (в Visual Studio Code или WebStorm), вы увидите полные названия методов. Для ɵvid это будет viewDef, функция, которая определяет представление.
Попробуйте изменить шаблон представления (app.component.html) и заново запустите компилятор angular (npm run compile). Посмотрите, как ваши изменения отразились в скомпилированном файле. Например, попробуйте изменить шаблон так:
1 |
<h1>Hi, {{title + title}}</h1> |
И посмотрите, что будет в скомпилированном виде.
Большая часть магии происходит в методе View_AppComponent_0, который состоит из двух частей: верхняя часть определяет вид – т.е. все элементы, которые необходимо создать, их атрибуты, текст и т.д., и нижняя – отвечает за определение изменений. Такое разделение позволяет Angular быть эффективным инструментом – верхняя часть запускается только один раз при создании представления, а нижняя часть запускается, когда Angular выполняет определение изменений.
Верхняя часть запускается только один раз при создании представления, а нижняя часть запускается, когда Angular выполняет определение изменений
Модули
Мы используем модули для организации наших приложений в компоненты и сервисы. Это задает контекст для разрешения компонентов и вставки зависимостей: компилятор заглядывает в модули и решает, какие компоненты доступны для других компонентов. В отличие от AngularJS, пайпы и компоненты не глобальные, они доступны только в контексте модуля, в котором объявлены или импортированы из другого модуля. Это предотвращает коллизии с именами при создании больших приложений на Angular.
Ниже мы взглянем на то, как вставка зависимостей реализована в Angular. Вы можете представить, что это будет какой-то объект или Map, который будет соединять все имена классов или токены с имплементациями. И действительно, AngularJS использовал объекты для этих целей. Недостаток объектов в том, что их индексы всегда конвертируются в строку, т.е. мы можем использовать только строки в качестве токенов при вставке зависимостей.
В Angular все по-другому – фреймворк взял другой подход, который позволяет использовать классы и другие объекты как токены при вставке зависимостей. И что же это?
Если открыть файл app.module.ngfactory.ts, там можно найти очень длинный метод getInternal(). Именно так выполнена вставка зависимостей в Angular. Сперва я подумал, что это было сделано для повышения производительности – возможно, куча if на тот момент была самым эффективным способом сопоставления значений в JS?
Я спросил команду Angular и мне сказали, что главная причина такого подхода в том, что он позволяет лучше удалять неиспользуемый код. Компилятор Closure умеет определять неиспользуемые сервисы и удалять их из финальной сборки.
Просто куча if’ов
Как только найдено совпадение, релевантный if запускает геттер, который создает объект сервиса при первом запуске. В остальных случаях он возвращает уже созданный ранее объект. Т.е. вставка зависимостей это просто куча if’ов.
Пример: геттер сервиса Compiler, создающий объект по требованию
В случае если какой-то сервис зависит от другого, то это будет известно во время компиляции, и мы можем найти подходящий сервис до получения объекта и передать его в качестве параметра в конструктор:
Сервис Testability зависит от NgZone, поэтому его объект будет создан и передан в конструктор сервиса Testability
Хотите еще больше изучить компилятор? Посмотрите замечательное выступление Tobias Bosch на ng-conf 2017. Он хорошо поработал с компилятором, он определенно знает его лучше 🙂
Повеселимся с инструментами
Мы можем написать еще много страниц про возможности инструментария в Angular Compiler, но я хочу вызвать один инструмент — Language Services.
Angular Language Services позволяет запускать компилятор в любимой IDE (WebStorm, Visual Studio Code), пользоваться автокомплитером, детальными ошибками при редактировании шаблонов. Если вы раньше не работали с ними, вам точно стоит попробовать: эти инструменты сделают из вас более лучшего разработчика. Если работаете с VSCode — вот расширение.
Minko Gechev также упоминал пару хороших способов использования в своем выступлении на ng-conf 2017 talk: Mad Science with the Angular Compiler. Помимо создания инструментов поверх компилятора для автоматической миграции между версиями Angular и визуализации структуры приложения, где-то он даже создает 3D-модель приложения со всеми компонентами в виде… деревьев!
Практика: сделай сам!
Мы лишь прошлись по поверхности компилятора Angular, изучать еще очень много. Я оставлю вам 3 упражнения, которые помогут вам лучше почувствовать принцип работы компилятора, если вы лучше понимаете все на практике. Во всех упражнения вам предстоит вручную попробовать выполнить трансформации, которые делает компилятор.
Прежде чем мы начнем, давайте переключим код, чтобы поглощать скомпилированный код, чтобы вы могли его менять и видеть результаты.
Во-первых, запустите ng serve и проверьте, что приложение работает (//localhost:4200), так как после изменения точки входа в приложение, webpack плагин Angular будет возвращать ошибку (она будет происходить при инициализации webpack). Обойти ее можно, запустив ng eject и перенастроив webpack на использование чистого typescript плагина, но здесь это не важно.
После запуска приложения измените src/main.ts, чтобы импортировать AppModuleNgFactory и вызвать bootstrapModuleFactory. Результат:
1 2 3 4 5 6 7 8 |
import { enableProdMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModuleNgFactory } from './app/app.module.ngfactory'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } platformBrowserDynamic().bootstrapModuleFactory(AppModuleNgFactory); |
Вот и все! Теперь Angular запускает ваш скомпилированный код. Чтобы проверить, просто измените app.component.html (добавьте любой текст), и приложение перезагрузится, но вы не увидите изменений, так как используете скомпилированную версию напрямую, а шаблон больше не скомпилирован в браузере (00:54:15). Можете еще раз проверить и изменить factory компонента (00:55:10).
В этих упражнениях редактируйте .ngfactory файлы напрямую. Не изменяйте HTML и не запускайте компилятор – это читерство 🙂
Рекомендую пользоваться преимуществами набора (наведение мыши/ ctrl-click или cmd-click на разных функциях, вызванных из скомпилированных файлов, чтобы увидеть определения), так вы поймете, что перед вами, намного быстрее.
Упражнение 1 – заголовок в верхнем регистре
Измените фэктори компонента, чтобы заголовок отображался в верхнем регистре (например, ПРИЛОЖЕНИЕ РАБОТАЕТ!).
Бонус: отобразите еще один заголовок под основным, но без верхнего регистра. Например, HTML-код, который будет отрисован в браузере:
1 2 |
<h1>APP WORKS!</h1> app works! |
Решение 1:02:50
Упражнение 2 –вставка зависимостей
Создайте новый сервис Emoji с помощью следующей команды:
1 |
ng generate service emoji |
Затем добавьте следующую строку в файл emoji-service.ts прямо перед constructor() {}:
1 |
cat = ''; |
Измените конструктор app.component.ts для вставки и использования этого сервиса:
1 2 3 |
constructor(emoji: EmojiService) { this.title += emoji.cat; } |
Не забудьте импортировать класс EmojiService в начале файла.
Конечно, это не сработает — emoji получим неопределенное значение в компоненте. Вам нужно понять, как изменить скомпилированные файлы таким образом, чтобы они регистрировали сервис как зависимость компонента и предоставляли ее во вставке зависимости модуля.
Подсказки:
Добавьте сервис в список зависимостей компонента в определении директивы (ɵdid) компонента приложения (в app.component.factory.ts).
Добавьте сервис в метод getInternal() в app.module.ngfactory.ts.
Решение 1:30:05
Упражнение 3 — ngOnInit
Добавьте метод ngOnInit() method в AppComponent:
1 2 3 |
ngOnInit() { this.title = 'onInit was run!'; } |
Почему Angular не запускает его? Как это исправить?
Подсказки:
Посмотрите на флаги вида (первый аргумент ɵdid в фэктори компонента). Доступные флаги заданы здесь.
Добавьте компонент в цикл определения изменений (передайте функцию обновления вида в качестве третьего аргумент в ɵvid внутри View_AppComponent_Host_0, как функция, переданная в ɵvid в View_AppComponent_0).
Если вы не знаете, что такое побитовые операции в JS или вам нужны еще подсказки 1:35:50.
Решение 1:49:00
Выводы
Компилятор Angular – удивительное произведение инженерной мысли. Надеюсь, этот пост дал вам возможность исследовать его и понять принцип его работы. Это лишь малая часть – еще очень многое нужно изучить, и теперь у вас есть базовые знания и инструмент для запуска компилятора, анализа его выходных данных и всей той магии, которую он делает.
Благодарю команду Angular за то, что постоянно раздвигают границы возможностей Angular и повышают производительность, Tobias Bosch и Igor Minar за то, что отвечали на мои многочисленные вопросы, пока я пытался разобраться в компиляторе. Отдельное спасибо Pascal Precht за ревью и за помощь в этом посте.
Автор: Uri Shaked
Источник: //blog.angularindepth.com/
Редакция: Команда webformyself.