В январе мы в Skyeng закончили перевод нашей платформы Vimbox с AngularJS на Angular 4. За время подготовки и перехода у нас накопилось много записей, посвященных планированию, решению возникающих проблем и новым конвенциям работы, и мы решили поделиться ими в трех статьях на Хабре. Надеемся, что наши заметки окажутся полезными структурно похожим на наш Vimbox проектам, которые только начали переезжать или собираются сделать это.
Зачем нам это нужно?
Во-первых, Angular во всем лучше AngularJS – он быстрее, легче, удобнее, в нем меньше багов (например, с ними помогает бороться типизация шаблонов). Об этом много сказано и написано, нет смысла повторяться. Это было понятно еще с Angular 2, однако год назад затевать переход было страшно: вдруг Google опять решит перевернуть все с ног на голову со следующей версией, без обратной совместимости? У нас большой проект, переход на по сути новый фреймворк требует серьезных ресурсов, и делать его раз в два года нам совсем не хочется. Angular 4 позволяет надеяться, что больше революций не будет, а значит, настало время мигрировать.
Во-вторых, мы хотели актуализировать технологии, используемые в нашей платформе. Если этого не делать по принципу «если что-то не сломалось, не надо его чинить», в какой-то момент мы перейдем черту, за которой дальнейший прогресс будет возможен только при условии переписывания платформы с нуля. Переходить на Angular рано или поздно придется все равно, но чем раньше это сделать, тем дешевле будет переход (объем кода все время растет, а плюсы от новой технологии мы получим раньше).
Наконец, третья важная причина: разработчики. AngularJS – пройденный этап, он выполняет свои задачи, но не развивается и развиваться никогда не будет; наша же платформа постоянно растет. У нас не очень большая команда, состоящая из сильных разработчиков, а сильные разработчики всегда интересуются новыми технологиями, им просто неинтересно иметь дело с устаревшим фреймворком. Переход на Angular делает и наши вакансии интереснее для сильных кандидатов; в ближайшие два-три года они будут вполне актуальны.
Как переходить?
Можно выполнять переход в параллельном режиме – платформа работает на AngularJS, мы пишем с нуля и тестируем новую версию, и в определенный момент просто переключаем тумблер. Второй вариант – гибридный режим, когда изменения происходят непосредственно на продакшне, где одновременно работает и AngularJS, и Angular. К счастью, этот режим хорошо продуман и задокументирован.
Выбор между гибридным и параллельным режимами перехода зависит от того, насколько активно развивается продукт. Наш разработчик, готовивший план мероприятия, имел опыт параллельного подхода в другой компании – но в том случае зависимостей было меньше (хотя кода примерно столько же), а главное, была возможность на месяц остановить все развитие и заниматься только переходом. Выбор режима зависит от того, можно ли позволить себе такую роскошь.
Для нас в параллельном переходе был риск: на время подготовки новой версии останавливается вся разработка, и как бы грамотно мы ни просчитали срок переезда, есть вероятность, что процесс затянется, мы во что-то упремся и вообще не будем понимать, что делать дальше. В гибридном режиме в этой ситуации мы можем просто остановиться и спокойно искать решение, поскольку на продакшне у нас по-прежнему актуальная рабочая версия; она, может, не так эффективно работает и чуть тяжелее, но никакие процессы не остановлены. В параллельном у нас бы случился откат назад с соответствующими потерями. Стоит заметить, что у нас процесс перехода действительно затянулся – планировали 412 часов, по факту получилось в два раза больше (830). Но при этом ничто не останавливалось, постоянно выкатывался новый функционал, все работало как надо.
Вообще, стоит учитывать, что гибридный переход – это не форс-мажор, это совершенно нормальная, дефолтная процедура по мнению разработчиков самого Angular; бояться его не нужно.
План
Последовательность действий выглядела так:
- Инициализация гибридного приложения: бутстрап ангуляра, который бутстрапит ангуляржс. Все остается как было, только теперь собираемся медленнее и запускаемся дольше (пока работает гибридный режим). Больше нет возможности кинуть контроллер на
head
, вся работа с тайтлом/фавиконками/метатегами выносится в сервисы, которые напрямую взаимодействуют с нужными элементами в хэде. - Перенос сервисов на ангуляр: самое легкое. Переписанные сервисы быстро делаются доступными из AngularJS, на котором пока работают компоненты. Начиная с самых простых, не имеющих зависимостей, к более сложным.
- Рисуем остальную сову: переносим базовые компоненты (GUI и все остальное, что не использует других компонентов/директив). Переносим компоненты снизу вверх, по возможности помодульно.
- Причесываем перышки: переносим компоненты страниц, выпиливаем AngularJS.
Правила переноса
Ну а теперь наконец перейдем к обещанным техническим деталям. Мы немного почистили эти записи, удалив лишние подробности, касающиеся только нашей платформы. Это совсем не универсальные решения, но, может, кому-то они послужат подспорьем для решения возникающих проблем.
Чтобы не городить стену текста, прячем все под спойлеры.
Как переносить отдельные элементы
Если в модуле, в котором что-то начинаем апгрейдить, нет модуля ангуляра, то создаём его и цепляем в основной модуль приложения:
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
Особенности (трансклуд, передача параметров, импорт svg)
Если в апгрейженном компоненте используется трансклуд (ng-content
), то при использовании компонента из шаблонов ангуляржса:
- не работают multi-slot трансклуды, только возможность пробросить всё одним куском через один
ng-content
; - в трансклуд такого компонента нельзя прокидывать ui-view, т.к. оно не будет работать (обломалось при попытке апгрейда viewport компонента);
- если компонент используется подобным образом, то либо откладываем его апгрейд до апгрейда всех мест, где его используют, либо делаем его копию для параллельной работы в уже апгрейженных компонентах.
При использовании ангуляр компонента в ангуляржс компоненте инпуты прописываются как для обычного ангуляр компонента (с использованием []
и ()
), но в 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;
}
- в 99% случаев
$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) {
- data атрибуты на компонентах прописываются как
attr.data-smth=""
или[attr.data-smth]=""
; require
в компонентах/директивах заменяется на инжект класса компонента прямо в конструкторе текущего компонентаcontructor(private parentComponent: ParentComponent)
. Ангуляр сам увидит, что это компонент, и зацепит его. Для тонкой подстройки есть декораторы@Host
(ищет среди родителей),@Self
(ищет прямо на компоненте),@Optional
(может присутствовать, а может нет, если нет, то переменная будет undefined). Накидывать можно сразу несколько@Host() @Optional() parentComponent: ParentComponent
. Рекварить можно компоненты/директивы в компоненты/директивы;- two-way биндинг в своих компонентах стал более явным и требует указания
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.
Комментарии (4)
bores
06.02.2018 15:49Скажите, а сколько по календарю занял переезд? Большая ли команда занималась непосредственно переписыванием? Насколько хорошо поддаётся распараллеливанию сей процесс?
deusdeorum
06.02.2018 17:36приблизительно так:
• принятое решение — 20 июня
• составленный roadmap — 1 июля
• работающий локально «гибрид» — 2 июля
• «гибрид» на проде — 1 сентября
• 100% переход — 4 января
итого: 6 месяцев
первая часть плохо параллелится: написание конвенций, миграция сборки, замена библиотек, разруливание сложных кейсов (о них в след. статьях). вторая часть, когда все описано и вопросы решены, параллелится хорошо, если есть независимые друг от друга модули.
у нас 50/50, между частью была сильная связность (приходилось обновлять одному человеку, либо подолгу разруливать конфликты), часть апгрейдилась абсолютно независимо. у нас апгрейд делали 2 человека, с большим числом людей мы не экспериментировали.
kaljan
это же такая боль
я думал проще заново написать)
princed
Мы так на Реакт перешли :)