Переход с AngularJS на Angular: жизнь после AngularJS (3/3)
- пятница, 9 февраля 2018 г. в 03:15:21
В заключительной части истории про миграцию на Angular мы поделимся избранными местами нашей внутренней документации, помогающими нашим разработчикам освоиться в новом фреймворке. Речь пойдет про особенности новых логики компиляции компонентов, Change Detection и концепции трансклуда. Это актуальные конвенции, использующиеся прямо сейчас при работе с Angular. Ну и в конце — несколько ссылок на англоязычные статьи и видео, которые мы рекомендуем коллегам.
Предыдущие серии: первая, про подготовку миграции и вторая, про особенности работы в гибридном режиме.
Одно из основных изменений в Angular – логика компиляции компонентов.
В AngularJS мы брали HTML первого компонента (самого верхнего), запихивали в DOM, ждали, пока браузер его или спарсит, или построит DOM фрагмент, или вставит в DOM, получали ссылку на полученный кусок DOM'а, парсили в нём ангулярские штуки (директивы, компоненты), повторяли для каждого найденного компонента.
В Angular используется компиляция шаблонов в js/ts в 2 вариантах — JiT и AoT. На старте приложения (в JiT режиме) фрейм берёт и компилирует все (вообще все) шаблоны всех компонентов из строки с HTML в JS-код, который создаст нужный кусок DOM. Т.е. парсинг и построение DOM фрагмента вообще никак не зависят от браузера, Angular это делает сам. После чего, начиная с верхнего компонента, он просто начинает выполнять соответствующий готовый JS-код, создающий куски DOM'а и сразу вставляющий их в нужные места дерева. После вставки дополнительный парсинг не требуется, т.к. компилятор всё уже разобрал.
AoT компиляция выполняется на сервере/локально при сборке, занимает некоторое время (не сильно много) и на выходе даёт в отдельном каталоге кучу данных для всех компонентов в виде TS файлов. Они цепляются к основному приложению, и компиляция TypeScript происходит уже с учётом скопмилированных шаблонов.
В AoT режиме шаблоны компилируются в TypeScript, а не JS. Это даёт статическую типизацию всех шаблонов. Пропустил переменную, указал её как private или обходишься с ней неподобающим образом (пытаешься пропихнуть объект в инпут компонента, ожидающего строку) – получаешь ошибку при сборке. Но это всё только в режиме сборки для продакшна, т.к. из-за тонны добавившегося типизированого кода сборка (компиляция ts -> js) несколько замедляется, и AoT режим при разработке выключен (ждём счастливых времён многопоточной компиляции или какой-нибудь магии, чтобы без страданий использовать AoT в девелоперском режиме).
Ссылки на тему: первая, вторая и третья.
В Angular больше нет глобального digest цикла (а также списка параметров, которые могут измениться, и их надо проверять), максимально используются нативные биндинги на события (addEventListener
и подобное), но сам чендж детекшен никуда не делся.
Как Angular узнаёт, что что-то произошло, без отдельных директив под события и специальных сервисов для таймаутов/интервалов?
Из языка Dart, на котором есть отдельная версия Angular, позаимствовали полезную концепцию зон, которая позволяет быть в курсе, когда в конкретной зоне началось выполнение какого-то кода, и когда оно закончилось с учётом асинхронных вызовов. Это всё оформилось в либу zone.js, позволяющую создавать/оперировать такими зонами и активно используется внутри движка Angular. Чтобы быть в курсе асинхронных событий, zone.js манкипатчит (подменяет на свою версию) соответствующие методы — addEventListener
, setTimeout
, setInterval
, методы Promise
и подобное. Это и избавило от необходимости отдельных директив для биндинга на события/доп сервисов. Соответственно, по окончании любого события, которое отлавливается в zone.js, Angular дёргает change detection, чтобы синхронизировать модель с DOM. В дев режиме детекшен дёргается 2 раза – второй, чтобы убедиться, что после первого ничего не изменилось, или дать по рукам (ошибка в консоли), если изменилось; в продакшене всегда ровно один раз (больше никаких десятикратных вызовов digest цикла и соответствующей ошибки).
Как проходит change detection, если нет глобального пула проверяемых параметров?
Это теперь работа компонентов. У каждого из них может быть набор параметров, которые так или иначе используются в DOM'е или специальных биндингах (HostBinding
, например). Такие параметры сохраняются в отдельный список в рамках компонента (и нигде больше не известны).
Когда Angular дёргает глобальный чендж детекшен, то каждый компонент (сверху вниз по дереву) сам занимается проверкой своих параметров. При этом у компонента может быть вообще отцеплен механизм change detection, тогда его параметры проверяться не будут в принципе. Или он может быть настроен на проверку только изменений его инпутов (ChangeDetectionStrategy.OnPush
), тогда он не только не будет ничего проверять, но ещё и целиком обрежет запуск проверок вложенных в него компонентов/директив. Таким образом, грамотно раскидав OnPush
, мы можем исключать из проверки на каждый тик целые ветви компонентов.
Также есть возможность выполнить кусок кода вне или (явно) внутри зоны Angular (в которой он ловит события и всё такое). Это полезно в случае, если мы цепляем стороннюю либу, которой чхать на все эти заморочки, она работает сама по себе; а мы не хотим, чтобы на каждое событие или таймаут внутри либы у нас дёргался детекшен по всему дереву компонентов. Чтобы потом сказать ангуляру, что у нас обновились данные (полученные из этой либы, которая была запущена вне прослушиваемой им зоны), мы явно прописываем выполнение какого-то действия (например, изменить локальное свойство компонента) в зоне ангуляра.
detectChanges и markForCheck
Если у нас компонент с detached CD или на компоненте (на текущем или выше по дереву) прописано ChangeDetectionStrategy.OnPush
, а у нас какие-то изменения, не подхватывающиеся автоматически, то есть 2 метода локального ChangeDetectorRef
сервиса — detectChanges
и markForCheck
:
markForCheck
: от текущего компонента (включительно) и вверх по дереву до самого рут-компонента помечает все вьюшки, как требующие проверки на ближайший тик глобального CD. Сам тик при этом не дёргается, а проверка вьюшек произойдёт, только если кто-нибудь ещё этот тик дёрнет (или он уже был дёрнут, но ожидает стабилизации изменений);detectChanges
: дёргает CD только для вьюшки текущего компонента, независимо от состояния детектора/инпутов. Для всех детей работает стандартная логика – если есть OnPush
, то смотрим инпуты, если change detector отцеплен, то ничего не смотрим и игнорим. Из особенностей работы – если компонент в процессе уничтожения (или считается уничтоженым), и его вьюшка отцеплена, то вызов detectChanges
в этот момент выкинет ошибку (например, решили дёрнуть по таймауту, а компонент уже уничтожен из-за смены роута, и мы дёргаем CD на несуществующей вьюшке). Ugly hack — проверка на уже убитую вьюшку (такие случаи надо избегать):if (!this.changeDetectorRef["destroyed"]) {
this.changeDetectorRef.detectChanges();
}
Когда что использовать:
OnPush
, и изменения пришли 'легальным' способом (из апи, по таймауту, как угодно внутри зоны с тиком глобального CD), — юзаем markForCheck
;detectChanges
и не забываем, что компонент мог быть уничтожен.ng-content
— НЕ то же самое, что ng-transclude
Концепция трансклюда в Angular немного изменилась (под влиянием того, как это сделано в вебкомпонентах, и особенностей работы движка/компилятора ангуляра). Внешне ng-content
выглядит и обладает теми же возможностями, что и ng-transclude
: прокинуть что-то в компонент, прокинуть в определённое место (select
атрибут).
Но есть одно мелкое отличие – отсутствует возможность указать дефолтное содержимое внутри ng-content
– и одно очень важное: за инициализацию контента, прокидываемого в ng-content
, отвечает тот, кто этот контент кидает, а не компонент, в котором лежит тег ng-content
.
Пояснение на примере:
@Component({
...
template: `
Visible: {{ visible }}
<ng-content *ngIf="visible"></ng-content>
`,
})
class ProjectionComponent implements OnInit {
public visible = false;
public ngOnInit() {
window.setTimeout(() => this.visible = true, 1000);
window.setTimeout(() => this.visible = false, 2000);
}
}
@Component({...})
class ChildComponent implements OnInit, AfterViewInit, OnDestroy {
public ngOnInit() {
console.log("Child component ngOnInit");
}
public ngAfterViewInit() {
console.log("Child component ngAfterViewInit");
}
public ngOnDestroy() {
console.log("Child component ngOnDestroy");
}
}
@Component({
...
template: `
<vim-projection>
<vim-child-component></vim-child-component>
</vim-projection>
`,
})
class ParentComponent {}
Основное внимание на ngIf
в ProjectionComponent
и лог консоли:
// сразу как появится `Visible` из шаблона `ProjectionComponent`
Child component ngOnInit
// спустя секунду
Child component ngAfterViewInit
// спустя 2 секунды
// ничего…
В первую секунду, несмотря на *ngIf="false"
, ChildComponent
был инициализирован (сработал OnInit
хук), а спустя 2 секунды не сработал OnDestroy
, хотя вроде компонент скрыли.
Т.е. независимо от того, что происходит в компоненте, содержащем ng-content
, всё, что туда пробрасывается, будет инициализировано (вызваны OnInit
хуки). Это может оказаться важно, если в инициализации компонента есть какие-то запросы, тяжёлые обработки и подобное.
Если надо скрыть такой контент, то этим должен заниматься тот, кто его прокидывает:
<vim-projection>
<vim-child-component *ngIf="visibleInParent"></vim-child-component>
</vim-projection>
или использовать интересный способ обойти это ограничение с помощью TemplateRef:
<div *ngIf="condition" class="smth1">
<ng-container *ngTemplateOutlet="contentTpl"></ng-container>
</div>
<div *ngIf="!condition" class="smth2">
<ng-container *ngTemplateOutlet="contentTpl"></ng-container>
</div>
<div *ngIf="condition2" class="smth1">
<ng-container *ngTemplateOutlet="contentWithSelectorTpl"></ng-container>
</div>
<div *ngIf="!condition2" class="smth2">
<ng-container *ngTemplateOutlet="contentWithSelectorTpl"></ng-container>
</div>
<ng-template #contentTpl><ng-content></ng-content></ng-template>
<ng-template #contentWithSelectorTpl><ng-content select="[some-attribute]"></ng-content></ng-template>
Из ограничений — вставлять такой ng-content
можно только в одно место за раз.
Также ng-content
не позволяет в шаблоне указать несколько одинаковых слотов, над которыми висит условие, когда их выводить.
<!-- так НЕЛЬЗЯ -->
<div *ngIf="smth" class="smth1">
<ng-content></ng-content>
</div>
<div *ngIf="!smth" class="smth2">
<ng-content></ng-content>
</div>
<!-- так тоже нельзя -->
<div *ngIf="smth" class="smth1">
<ng-content select="[some-selector]"></ng-content>
</div>
<div *ngIf="!smth" class="smth2">
<ng-content select="[some-selector]"></ng-content>
</div>
В гугле множество всего по ангуляру 2+, можно смело искать на любые темы, которые будут сходу/по докам не понятны. Народ очень активно с релиза (да и до него) разбирал фрейм на части и выяснял, как оно работает.
Вот немного полезных ссылок:
ElementRef, TemplateRef, ViewContainerRef
ViewChildren, ContentChildren, QueryList
ng-template и микросинтаксис структурных директив
Ну и напоследок, как всегда, напоминаем, что мы всегда ищем крутых разработчиков (не только Angular).