Хабрахабр

Переход с 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. Продолжение завтра!

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть