Переход с AngularJS на Angular: цели, планы и правила переноса элементов (1/3)
- среда, 7 февраля 2018 г. в 03:13:25

В январе мы в Skyeng закончили перевод нашей платформы Vimbox с AngularJS на Angular 4. За время подготовки и перехода у нас накопилось много записей, посвященных планированию, решению возникающих проблем и новым конвенциям работы, и мы решили поделиться ими в трех статьях на Хабре. Надеемся, что наши заметки окажутся полезными структурно похожим на наш Vimbox проектам, которые только начали переезжать или собираются сделать это.
Во-первых, Angular во всем лучше AngularJS – он быстрее, легче, удобнее, в нем меньше багов (например, с ними помогает бороться типизация шаблонов). Об этом много сказано и написано, нет смысла повторяться. Это было понятно еще с Angular 2, однако год назад затевать переход было страшно: вдруг Google опять решит перевернуть все с ног на голову со следующей версией, без обратной совместимости? У нас большой проект, переход на по сути новый фреймворк требует серьезных ресурсов, и делать его раз в два года нам совсем не хочется. Angular 4 позволяет надеяться, что больше революций не будет, а значит, настало время мигрировать.
Во-вторых, мы хотели актуализировать технологии, используемые в нашей платформе. Если этого не делать по принципу «если что-то не сломалось, не надо его чинить», в какой-то момент мы перейдем черту, за которой дальнейший прогресс будет возможен только при условии переписывания платформы с нуля. Переходить на Angular рано или поздно придется все равно, но чем раньше это сделать, тем дешевле будет переход (объем кода все время растет, а плюсы от новой технологии мы получим раньше).
Наконец, третья важная причина: разработчики. AngularJS – пройденный этап, он выполняет свои задачи, но не развивается и развиваться никогда не будет; наша же платформа постоянно растет. У нас не очень большая команда, состоящая из сильных разработчиков, а сильные разработчики всегда интересуются новыми технологиями, им просто неинтересно иметь дело с устаревшим фреймворком. Переход на Angular делает и наши вакансии интереснее для сильных кандидатов; в ближайшие два-три года они будут вполне актуальны.
Можно выполнять переход в параллельном режиме – платформа работает на AngularJS, мы пишем с нуля и тестируем новую версию, и в определенный момент просто переключаем тумблер. Второй вариант – гибридный режим, когда изменения происходят непосредственно на продакшне, где одновременно работает и AngularJS, и Angular. К счастью, этот режим хорошо продуман и задокументирован.
Выбор между гибридным и параллельным режимами перехода зависит от того, насколько активно развивается продукт. Наш разработчик, готовивший план мероприятия, имел опыт параллельного подхода в другой компании – но в том случае зависимостей было меньше (хотя кода примерно столько же), а главное, была возможность на месяц остановить все развитие и заниматься только переходом. Выбор режима зависит от того, можно ли позволить себе такую роскошь.
Для нас в параллельном переходе был риск: на время подготовки новой версии останавливается вся разработка, и как бы грамотно мы ни просчитали срок переезда, есть вероятность, что процесс затянется, мы во что-то упремся и вообще не будем понимать, что делать дальше. В гибридном режиме в этой ситуации мы можем просто остановиться и спокойно искать решение, поскольку на продакшне у нас по-прежнему актуальная рабочая версия; она, может, не так эффективно работает и чуть тяжелее, но никакие процессы не остановлены. В параллельном у нас бы случился откат назад с соответствующими потерями. Стоит заметить, что у нас процесс перехода действительно затянулся – планировали 412 часов, по факту получилось в два раза больше (830). Но при этом ничто не останавливалось, постоянно выкатывался новый функционал, все работало как надо.
Вообще, стоит учитывать, что гибридный переход – это не форс-мажор, это совершенно нормальная, дефолтная процедура по мнению разработчиков самого Angular; бояться его не нужно.
Последовательность действий выглядела так:
head, вся работа с тайтлом/фавиконками/метатегами выносится в сервисы, которые напрямую взаимодействуют с нужными элементами в хэде.Ну а теперь наконец перейдем к обещанным техническим деталям. Мы немного почистили эти записи, удалив лишние подробности, касающиеся только нашей платформы. Это совсем не универсальные решения, но, может, кому-то они послужат подспорьем для решения возникающих проблем.
Чтобы не городить стену текста, прячем все под спойлеры.
Если в модуле, в котором что-то начинаем апгрейдить, нет модуля ангуляра, то создаём его и цепляем в основной модуль приложения:
import {NgModule} from "@angular/core";
@NgModule({
//
});
export class SmthModule {}
@NgModule({
imports: [
...
SmthModule,
],
});
export class AppModule {}Если ангуляржс модуль ещё остаётся живым, то новый именуем с постфиксом .new. Выпиливаем постфикс вместе со старым модулем ангуляржса.
В хорошем случае добавляем декоратор, убираем default из экспорта, правим импорты (т.к. убрали дефолт), импортируем в ангуляр модуле, даунгрейдим в ангуряржс модуле:
import {Injectable} from "@angular/core";
@Injectable()
export class SmthService {
...
}
// angular module
@NgModule({
providers: [
...
SmthService,
],
});
// angularjs module
import {downgradeInjectable} from "@angular/upgrade/static";
...
.factory("vim.smth", downgradeInjectable(SmthService))Сервис остаётся доступен по старому имени в ангуряржс и не требует дополнительной настройки.
Хороший вариант подразумевает: все инжектуные сервисы уже переехали на ангуляр, не используются какие-то специфические вещи по типу templateCache или compiler.
В остальных 95% случаев страдаем, сначала апгрейдя то, что инжектится, избавляемся от всяких странных ангуляржс сервисов и т.д.
Докидываем к контроллеру декоратор с мета-данными, проставляем декораторы инпутам/аутпутам и переносим их в начало класса:
import {Component, Input, Output, EventEmitter} from "@angular/core";
@Component({
// селектор через `-` как будет использоваться в шаблоне, а не camelCase
selector: "vim-smth",
// при сборке специальный лоадер заменит на require("./smth.html")
templateUrl: "smth.html",
})
export class SmthComponent {
@Input() smth1: string;
@Output() smthAction = new EventEmitter<void>();
...
}
// angular module
@NgModule({
declarations: [
...
SmthComponent,
],
// дублируем сюда если компонент используется в компонентах других модулей, иначе он будет доступен только компонентам этого модуля
exports: [
...
SmthComponent,
],
});
// angularjs module
import {downgradeInjectable} from "@angular/upgrade/static";
...
.directive("vimSmth", downgradeComponent({ component: SmthComponent }) as ng.IDirectiveFactory)Все инжекнутые сервисы, все require компоненты (как их цеплять — ниже во Всякое) и все компоненты/директивы/фильтры, используемые внутри шаблона, должны быть на ангуляре.
Все используемые в шаблоне переменные компонента должны быть объявлены как public, иначе упадёт на AoT сборке.
Если компонент получает все данные для вывода из компонента выше (через инпуты), то смело пишем ему в мета-данные changeDetection: ChangeDetectionStrategy.OnPush. Это говорит ангуляру, что синкать шаблон с данными (пускать change detection для этого компонента) он будет, только если изменится любой из инпутов компонента. В идеале бОльшая часть компонентов должна быть в таком режиме (но у нас вряд ли, т.к. очень крупные компоненты, получающие данные для вывода через сервисы).
То же самое, что у компонента, только нет шаблона и декоратор @Directive. Закидывается в модуль туда же, экспортировать для использования в компонентах других модулей надо так же.
Селектор в camelCase, так же используется в шаблонах компонентов.
Теперь он @Pipe и должен имплементить PipeTransform интерфейс. В модуль закидывается туда же, куда и компоненты/директивы, и так же надо экспортировать, если используется в других модулях.
Селектор в camelCase, так же используется в шаблонах компонентов.
Директивы и фильтры ангуляра нельзя использовать в шаблонах ангуляржс компонентов и наоборот. Между фреймворками пробрасываются только сервисы и компоненты.
Во-первых, избавляемся от export default, т.к. AoT компилятор в него не может.
Во-вторых, из-за текущей структуры модулей (очень крупные) и использования интерфейсов (кладём кучей в тот же файл, где классы) мы словили весёлый баг с импортом таких интерфейсов и их использованием с декораторами: если интерфейс импортируется из файла, содержащего экспорты не только интерфейсов, но и, например, классов/констант, и такой интерфейс используется для типизации рядом с декоратором (например, @Input() smth: ISmth), то компилятор выдаст ошибку импорта export 'ISmth' was not found. Это может фикситься или выносом всех интерфейсов в отдельный файл (что плохо из-за крупных модулей, такой файл будет в десяток экранов), или заменой интерфейсов на классы. Замена на классы не прокатит, т.к. нельзя наследовать от нескольких родителей.
Выбранное решение: создать в каждом модуле каталог interface, в котором будут лежать файлы с именованием по сущности, содержащие соответствующие интерфейсы (например room, step, content, workbook, homework). Соответственно, все интерфейсы, используемые не локально, кладутся туда и импортируются из таких каталогов-файлов.
Более подробное описание проблемы:
https://github.com/angular/angular-cli/issues/2034#issuecomment-302666897
https://github.com/webpack/webpack/issues/2977#issuecomment-245898520
Если в апгрейженном компоненте используется трансклуд (ng-content), то при использовании компонента из шаблонов ангуляржса:
ng-content;При использовании ангуляр компонента в ангуляржс компоненте инпуты прописываются как для обычного ангуляр компонента (с использованием [] и ()), но в kebab-case
<vim-angular-component [some-input]=""
(some-output)="">
</vim-angular-component>При переписывании такого шаблона на ангуляр правим kebab-case на camelCase.
Не прокатит, т.к. на него будет ругаться AoT компилятор. Поэтому импорт тех же свгшек выносим в ts файл и пробрасываем через св-во компонента.
было:
<span>
${require('!html-loader!image-webpack-loader?{}!./images/icon.svg')}
</span>стало:
const imageIcon = require<string>("!html-loader!image-webpack-loader?{}!./images/icon.svg");
public imageIcon = imageIcon;
<span [innerHTML]="imageIcon | vimBaseSafeHtml">
</span>Или для использования через img
было:
<img ng-src="${require('./images/icon.svg')}" />стало:
const imageIcon = require<string>("./images/icon.svg");
public imageIcon = imageIcon;
<img [src]="imageIcon | vimBaseSafeUrl" />$compile больше нет, как нет и компиляции из строки (на самом деле есть небольшим хаком, но тут о том, как жить в 95% случаев без $compile).
Динамически вставляемые компоненты пробрасываются следующим образом:
@Component({...})
class DynamicComponent {}
@NgModule({
declarations: [
...
DynamicComponent,
],
entryComponents: [
DynamicComponent,
],
})
class SomeModule {}
// использование
@Component({
...
template: `
<vim-base-dynamic-component [component]="dynamicComponent"></vim-base-dynamic-component>
`
})
class SomeComponent {
public dynamicComponent = DynamicComponent;
}Класс вставляемого компонента может прокидываться через сервис, инпуты или ещё как-либо.
vim-base-dynamic-component — это уже написанный компонент для динамической вставки других компонентов с поддержкой инпутов/аутпутов (в будущем, если понадобится).
Если нужно выводить разные шаблоны по условию, и для этого использовался динамический templateUrl, заменяем это на структурную директиву и разбиваем компонент на три. Пример для разделения вывода мобилка/не мобилка:
запрос/обработка данных
отображение для мобилки
отображение для десктопов
Первый компонент имеет минимальный шаблон и занимается работой с данными, обработкой действий юзера и тому подобное (такой шаблон, из-за его краткости, есть смысл класть тут же в template св-во компонента через `` вместо отдельного html файла и templateUrl). Например:
@Component({
selector: "...",
template: `
<some-component-mobile *vimBaseIfMobile="true"
[data]="data"
(changeSmth)="onChangeSmth($event)">
</some-component-mobile>
<some-component-desktop *vimBaseIfMobile="false"
[data]="data"
(changeSmth)="onChangeSmth($event)">
</some-component-desktop>
`,
})vimBaseIfMobile — структурная директива (в данном случае прямой аналог ngIf), отображающая соответствующий компонент по внутреннему условию и переданному параметру.
Компоненты для мобилки и десктопа получают данные через инпуты, шлют какие-то события через output и занимаются только выводом необходимого. Вся сложная логика, обработки, изменение данных — в основном компоненте который их выводит. В таких компонентах (декстоп/мобайл) можно смело прописывать changeDetection: ChangeDetectionStrategy.OnPush.
Открываем app/entries/angularjs-services-upgrade.ts и по примеру уже имеющегося копипастим (всё в рамках этого файла):
// EXAMPLE: copy-paste, fix naming/params, add to module providers at the bottom, use
// -----
import LoaderService from "../service/loader";
// NOTE: this function MUST be provided and exported for AoT compilation
export function loaderServiceFactory(i: any) {
return i.get(LoaderService.ID);
}
const loaderServiceProvider = {
provide: LoaderService,
useFactory: loaderServiceFactory,
deps: [ "$injector" ]
};
// -----
@NgModule({
providers: [
loaderServiceProvider,
]
})
export class AngularJSServicesUpgrade {}Т.е. копируем имеющийся блок, импортируем нужный сервис, правим под него названия константы/функции, правим в них используемый сервис и его название (чаще всего вместо SmthService.ID надо будет вставить просто строкой имя, под которым сервис доступен (инжектится) в ангуляржсе), добавляем новую константу smthServiceProvider в список провайдеров в конце файла.
Такой сервис используется как нативный ангуляровский: просто инжектим в конструкторе по классу.
Кладём в файл с оригинальным компонентом (в начало) следующую заглушку, которая позволит прокинуть компонент в ангуляр окружение:
import {Directive, ElementRef, Injector, Input, Output, EventEmitter} from "@angular/core";
import {UpgradeComponent} from "@angular/upgrade/static";
@Directive({
/* tslint:disable:directive-selector */
selector: "vim-smth"
})
/* tslint:disable:directive-class-suffix */
export class SmthComponent extends UpgradeComponent {
@Input() smth: boolean;
@Output() someAction: EventEmitter<string>;
constructor(elementRef: ElementRef, injector: Injector) {
super("vimSmth", elementRef, injector);
}
}
@NgModule({
declarations: [
...
SmthComponent,
]
})
export class SmthModule {Обращаем внимание, что в данном случае используется декоратор Directive вместо Component, это особенность того, как ангуляр будет это обрабатывать.
Не забываем прописать все Input/Output (биндинги из оригинального компонента) и прописать компонент в declarations соответствующего модуля.
В дальнейшем, при апгрейде этого компонента, такая заглушка станет реальным компонентом ангуляра.
Если компонент (а точнее, старая директива-компонент) инжектит $attrs в контроллер/link функцию, то такой компонент нельзя прокинуть в ангуляр из ангуляржса, и его нужно апгрейдить или класть рядом апгрейженную копию для ангуляра.
Отключение ошибок tslint'a нужно, чтобы не ругался на несоответствие имени селектора и класса декоратору директивы. Эти строчки (комментарии) надо убрать после апгрейда компонента.
$q заменяется на нативные Promise. У них нет finally, но это пофиксилось полифилом core.js/es7.promise.finally и теперь он есть. У него также нет deferred, добавлен ts-deferred, чтобы не писать велосипед каждый раз;$timeout и $interval используем нативные window.setTimeout и window.setInterval;ng-show="visible" биндимся на аттрибут [hidden]="!visible";track by теперь всегда должен быть методом, указывается как (не забываем про постфикс Track у метода):*ngFor="let item of items; trackBy: itemTrack"
public itemTrack(_index: number, item: IItem): number {
return item.id;
}$digest, $apply, $evalAsync и подобное выпиливаются без замены;constructor(private someService: SomeService), ангуляр сам поймёт, откуда его взять;constructor(private element: ElementRef) и инициализирован в хуке AfterViewInit (ElementRef это не сам DOM объект, он доступен по this.element.nativeElement);ng-include нет без замены, используем динамическое создание компонентов;angular.extend, angular.merge, angular.forEach и подобное отсутствует, используем нативный js и lodash;angular.element и все его методы отсутствуют. Пользуемся @ViewChild/@ContentChild и работаем через нативный js;OnPush — инжектим private changeDetectorRef: ChangeDetectorRef и дёргаем this.changeDetectorRef.markForCheck();$ctrl. — доступ к св-вам и методам напрямую по именам;ng-bind-html="smth" -> [innerHTML]="smth"$sce -> import {DomSanitizer} from "@angular/platform-browser";ng-pural -> [ngPlural] https://angular.io/api/common/NgPluralngClass не может так[ngClass]="{
[ styles.active ]: visible,
[ styles.smth ]: smth
}"поэтому заменяем на массив
[ngClass]="[
visible ? styles.active : '',
smth ? styles.smth : ''
]"ui-router сервисов импортируются из @uirouter/core и инжектятся без старого префикса $import {StateService, TransitionService} from "@uirouter/core";
constructor(stateService: StateService,
transitionService: TransitionService) {attr.data-smth="" или [attr.data-smth]="";require в компонентах/директивах заменяется на инжект класса компонента прямо в конструкторе текущего компонента contructor(private parentComponent: ParentComponent). Ангуляр сам увидит, что это компонент, и зацепит его. Для тонкой подстройки есть декораторы @Host (ищет среди родителей), @Self (ищет прямо на компоненте), @Optional (может присутствовать, а может нет, если нет, то переменная будет undefined). Накидывать можно сразу несколько @Host() @Optional() parentComponent: ParentComponent. Рекварить можно компоненты/директивы в компоненты/директивы;Output с тем же именем и постфиксом Change.export class SmthComponent {
@Input() variable: string;
@Output() variableChange = new EventEmitter<string>();
<vim-smth [(variable)]="localVar"></vim-smth><!-- angular -->
<ng-content></ng-content>
<!-- angularjs -->
<vim-angular-component>
transcluded data
</vim-angular-component>В следующих частях мы расскажем про особенности работы в гибридном режиме, а также про новые конвенции, к которым нам предстоит привыкать с Angular. Продолжение завтра!