В январе мы в Skyeng закончили перевод нашей платформы Vimbox с AngularJS на Angular 4. За время подготовки и перехода у нас накопилось много записей, посвященных планированию, решению возникающих проблем и новым конвенциям работы, и мы решили поделиться ими в трех статьях на Хабре. Надеемся, что наши заметки окажутся полезными структурно похожим на наш Vimbox проектам, которые только начали переезжать или собираются сделать это.


Зачем нам это нужно?


Во-первых, Angular во всем лучше AngularJS – он быстрее, легче, удобнее, в нем меньше багов (например, с ними помогает бороться типизация шаблонов). Об этом много сказано и написано, нет смысла повторяться. Это было понятно еще с Angular 2, однако год назад затевать переход было страшно: вдруг Google опять решит перевернуть все с ног на голову со следующей версией, без обратной совместимости? У нас большой проект, переход на по сути новый фреймворк требует серьезных ресурсов, и делать его раз в два года нам совсем не хочется. Angular 4 позволяет надеяться, что больше революций не будет, а значит, настало время мигрировать.


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


Наконец, третья важная причина: разработчики. AngularJS – пройденный этап, он выполняет свои задачи, но не развивается и развиваться никогда не будет; наша же платформа постоянно растет. У нас не очень большая команда, состоящая из сильных разработчиков, а сильные разработчики всегда интересуются новыми технологиями, им просто неинтересно иметь дело с устаревшим фреймворком. Переход на Angular делает и наши вакансии интереснее для сильных кандидатов; в ближайшие два-три года они будут вполне актуальны.


Как переходить?


Можно выполнять переход в параллельном режиме – платформа работает на AngularJS, мы пишем с нуля и тестируем новую версию, и в определенный момент просто переключаем тумблер. Второй вариант – гибридный режим, когда изменения происходят непосредственно на продакшне, где одновременно работает и AngularJS, и Angular. К счастью, этот режим хорошо продуман и задокументирован.


Выбор между гибридным и параллельным режимами перехода зависит от того, насколько активно развивается продукт. Наш разработчик, готовивший план мероприятия, имел опыт параллельного подхода в другой компании – но в том случае зависимостей было меньше (хотя кода примерно столько же), а главное, была возможность на месяц остановить все развитие и заниматься только переходом. Выбор режима зависит от того, можно ли позволить себе такую роскошь.


Для нас в параллельном переходе был риск: на время подготовки новой версии останавливается вся разработка, и как бы грамотно мы ни просчитали срок переезда, есть вероятность, что процесс затянется, мы во что-то упремся и вообще не будем понимать, что делать дальше. В гибридном режиме в этой ситуации мы можем просто остановиться и спокойно искать решение, поскольку на продакшне у нас по-прежнему актуальная рабочая версия; она, может, не так эффективно работает и чуть тяжелее, но никакие процессы не остановлены. В параллельном у нас бы случился откат назад с соответствующими потерями. Стоит заметить, что у нас процесс перехода действительно затянулся – планировали 412 часов, по факту получилось в два раза больше (830). Но при этом ничто не останавливалось, постоянно выкатывался новый функционал, все работало как надо.


Вообще, стоит учитывать, что гибридный переход – это не форс-мажор, это совершенно нормальная, дефолтная процедура по мнению разработчиков самого Angular; бояться его не нужно.


План


Последовательность действий выглядела так:


  1. Инициализация гибридного приложения: бутстрап ангуляра, который бутстрапит ангуляржс. Все остается как было, только теперь собираемся медленнее и запускаемся дольше (пока работает гибридный режим). Больше нет возможности кинуть контроллер на head, вся работа с тайтлом/фавиконками/метатегами выносится в сервисы, которые напрямую взаимодействуют с нужными элементами в хэде.
  2. Перенос сервисов на ангуляр: самое легкое. Переписанные сервисы быстро делаются доступными из AngularJS, на котором пока работают компоненты. Начиная с самых простых, не имеющих зависимостей, к более сложным.
  3. Рисуем остальную сову: переносим базовые компоненты (GUI и все остальное, что не использует других компонентов/директив). Переносим компоненты снизу вверх, по возможности помодульно.
  4. Причесываем перышки: переносим компоненты страниц, выпиливаем 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.


require в шаблонах для картинок/свг

Не прокатит, т.к. на него будет ругаться 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

$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 нет

Если нужно выводить разные шаблоны по условию, и для этого использовался динамический 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/NgPlural
  • ngClass не может так

[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)


  1. kaljan
    06.02.2018 13:56

    это же такая боль
    я думал проще заново написать)


    1. princed
      06.02.2018 15:14

      Мы так на Реакт перешли :)


  1. bores
    06.02.2018 15:49

    Скажите, а сколько по календарю занял переезд? Большая ли команда занималась непосредственно переписыванием? Насколько хорошо поддаётся распараллеливанию сей процесс?


    1. deusdeorum
      06.02.2018 17:36

      приблизительно так:
      • принятое решение — 20 июня
      • составленный roadmap — 1 июля
      • работающий локально «гибрид» — 2 июля
      • «гибрид» на проде — 1 сентября
      • 100% переход — 4 января
      итого: 6 месяцев

      первая часть плохо параллелится: написание конвенций, миграция сборки, замена библиотек, разруливание сложных кейсов (о них в след. статьях). вторая часть, когда все описано и вопросы решены, параллелится хорошо, если есть независимые друг от друга модули.

      у нас 50/50, между частью была сильная связность (приходилось обновлять одному человеку, либо подолгу разруливать конфликты), часть апгрейдилась абсолютно независимо. у нас апгрейд делали 2 человека, с большим числом людей мы не экспериментировали.