Переход с AngularJS на Angular: цели, планы и правила переноса элементов (1/3)


    В январе мы в 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.

    Skyeng
    Крутейшая edtech-команда страны. Удаленная работа

    Comments 5

      +2
      это же такая боль
      я думал проще заново написать)
        –1
        Мы так на Реакт перешли :)
        0

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

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

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

          у нас 50/50, между частью была сильная связность (приходилось обновлять одному человеку, либо подолгу разруливать конфликты), часть апгрейдилась абсолютно независимо. у нас апгрейд делали 2 человека, с большим числом людей мы не экспериментировали.
          0
          Интересно узнать объём вашего проекта. В качестве метрики: сколько .js + .html файлов было? Я хочу как-то соотнести 6 месяцев из вашего опыта с тем, что предстоит мне.

          Only users with full accounts can post comments. Log in, please.