Переход с AngularJS на Angular: проблемы и решения гибридного режима (2/3)


    Переход в гибридном режиме — естественная процедура, хорошо подготовленная и описанная командой Angular. Тем не менее, на практике возникают сложности и затыки, которые приходится решать на лету. В сегодняшнем продолжении нашей статьи про миграцию на Angular мы расскажем про проблемы, с которыми столкнулась команда Skyeng, и поделимся своими решениями.


    Первая часть, третья часть.


    Динамическая компиляция из строки


    В angularjs все очень просто:


    const compiledContent = this.$compile(template)(scope);
    this.$element.append(compiledContent);

    А в Angular не совсем.


    Первое решение — взять вариант из ангуляра, через JiT компилятор. Оно подразумевает, что в продакшен сборку, несмотря на AoT компиляцию статичных компонентов, всё равно тащится тяжёленький компилятор для сборки динамических шаблонов. Выглядит как-то так:


    // в некотором модуле
    import {NgModule, Compiler} from "@angular/core";
    import {JitCompilerFactory} from "@angular/compiler";
    
    export function compilerFactory() {
      return new JitCompilerFactory([{ useDebug: false, useJit: true }]).createCompiler();
    }
    
    @NgModule({
      providers: [
        { provide: Compiler, useFactory: compilerFactory },
        ...
      ],
      declarations: [
        DynamicTemplateComponent,
      ]
    })
    export class DynamicModule {
    }
    
    // компонент
    import {
      Component, Input, Injector, Compiler, ReflectiveInjector, ViewContainerRef,
      NgModule, ModuleWithProviders, ComponentRef, OnInit, OnChanges, SimpleChanges,
    } from "@angular/core";
    import {COMPILER_PROVIDERS} from "@angular/compiler";
    
    @Component({
      selector: "vim-base-dynamic-template",
      template: "",
    })
    export class DynamicTemplateComponent implements OnInit, OnChanges {
      @Input() moduleImports?: ModuleWithProviders[];
      @Input() template: string;
    
      private componentRef: ComponentRef<any> | null = null;
      private dynamicCompiler: Compiler;
      private dynamicInjector: Injector;
    
      constructor(
        private injector: Injector,
        private viewContainerRef: ViewContainerRef,
      ) {
      }
    
      public ngOnInit() {
        this.dynamicInjector = ReflectiveInjector.resolveAndCreate(COMPILER_PROVIDERS, this.injector);
        this.dynamicCompiler = this.injector.get(Compiler);
    
        this.compileComponent(this.template, this.moduleImports);
      }
    
      public ngOnChanges(changes: SimpleChanges) {
        if (this.dynamicCompiler && changes.template) {
          this.compileComponent(this.template, this.moduleImports);
        }
      }
    
      private compileComponent(template: string, imports: ModuleWithProviders[] = []): void {
        if (this.componentRef) {
          this.componentRef.destroy();
        }
    
        const component = Component({ template })(class {});
        const module = NgModule({ imports, declarations: [ component ] })(class {});
    
        this.dynamicCompiler.compileModuleAndAllComponentsAsync(module)
          .then(factories => factories.componentFactories.filter(factory => factory.componentType === component)[0])
          .then(componentFactory => {
            this.componentRef = this.viewContainerRef.createComponent(
              componentFactory,
              null,
              this.viewContainerRef.injector
            );
          });
      }
    }

    И вроде бы всё относительно неплохо (толстый компилятор в бандле всё равно нивелируется горой других либ и кодом самого проекта, если это что-то большее, чем todo list), но тут конкретно мы въехали вот в такую проблему:



    https://github.com/angular/angular/issues/19902


    Шесть секунд на компиляцию одного из наших слайдов с упраженениями, пусть и довольно большого. При том, что три секунды идёт непонятный простой. Судя по ответу в issue, ситуация ближайшие месяцы не изменится, и нам пришлось искать другое решение.


    Также оказалось, что мы не можем в этом случае задействовать уже скомпилированные при AoT сборке фабрики компонентов, используемых в слайдах, т.к. нет возможности заполнить кэш JiT компилятора. Такие компоненты по сути компилировались два раза — на бэкэнде при AoT сборке и в рантайме при компиляции первого слайда.


    Вторым решением на скорую руку была сделана компиляция шаблонов через $compile из angularjs (у нас же всё ещё гибрид и ангуляржс):


    class DynamicTemplateController {
      static $inject = [
        "$compile",
        "$element",
        "$scope",
      ];
    
      public template: string;
    
      private compiledScope: ng.IScope;
    
      constructor(
        private $compile: ng.ICompileService,
        private $element: ng.IAugmentedJQuery,
        private $scope: ng.IScope,
      ) {
      }
    
      public $onChanges() {
        this.compileTemplate();
      }
    
      private compileTemplate(): void {
        if (this.compiledScope) {
          this.compiledScope.$destroy();
          this.$element.empty();
        }
    
        this.compiledScope = this.$scope.$new(true);
    
        this.$element.append(this.$compile(this.template)(this.compiledScope));
      }
    }

    Компонент ангуляра использовал апгрейженную версию DynamicTemplateComponent из ангуляржса, который использовал $compile сервис для сборки шаблона, в котором все компоненты были даунгрейжены из ангуляра. Такая короткая прослойка angular -> angularjs ($compile) -> angular.


    Этот вариант имеет немного проблем, например, невозможность инжекта компонентов через компонент-сборщик из ангуляржса, но главное — он не будет работать после окончания апгрейда и выпиливания ангуляржса.


    Дополнительное гугление и задалбывание народа в gitter'е ангуляра привело к третьему решению: вариации на тему того, что используется непосредственно на офф сайте ангуляра для подобного кейса, а именно вставке шаблона напрямую в DOM и ручной инициализации всех известных компонентов поверх найденных тегов. Код по ссылке.


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


    Из минусов:


    • немного коряво проставляем инжекторы для корректной работы инжектов родителей;
    • небольшой хак для поддержки content projection с select'ами (вытащили пару методов из @angular/upgrade модуля);
    • инпуты только статичные и только строковые;
    • полное доверие пришедшему хтмлу (вставляется без обработки, т.к. может содержать инлайн стили и всякое другое непотребство из нашей админки);
    • некорректная последовательность инит хуков для родителей-детей (сначала OnInit/AfterViewInit родителей, только потом OnInit/AfterViewInit детей).

    Но в целом мы имеем довольно шустрый способ инициализировать динамический шаблон, в основе своей решающий конкретно нашу задачу средствами ангуляра и без лагов, как с JiT компилятором.


    Казалось бы, на этом можно остановиться, но для нас проблема до конца так и не решилась из-за того, как ангуляр работает с content projection. Нам необходимо содержимое некоторых компонентов (по типу спойлеров) инициализировать только при определённых условиях, что невозможно при использовании обычного ng-content, а ng-template мы не можем вставить из-за особенностей способа сборки контента. В дальнейшем будем искать более гибкое решение, возможно, заменим html-контент на JSON структуру, по которой обычными ангуляр-компонентами будем рендерить слайд с учётом динамического показа/скрытия части контента (потребует использования самописных компонентов вместо ng-content).


    Кому-то может подойти четвёртый вариант, который станет официально доступен в виде беты с релизом angular 6 — @angular/elements. Это custom elements, реализованные через ангуляр. Регистрируем по некоторому тегу, любым способом вставляем этот тег в DOM, и на нём автоматически инициализируется полноценный ангуляр компонент со всем привычным функционалом. Из ограничений — взаимодействие с основным приложением только через события на таком элементе.


    Информация по ним пока доступна только в виде нескольких выступлений с ng-конференций, статей по этим выступлениям и техническим демкам:



    Сайт ангуляра планирует сразу же, с первой версией @angular/elements, перейти на них вместо текущего способа сборки:



    Change Detection


    В гибриде есть несколько неприятных проблем с работой CD между ангуляром и ангуляржсом, а именно:


    AngularJS в зоне Angular


    Сразу после инициализации гибрида мы получим просадку по производительности из-за того, что angularjs код будет запускаться в зоне angular'а, и любые setTimeout/setInterval и другие асинхронные действия из кода angularjs и из используемых thirdparty библиотек будут дёргать тик CD angular'а, который дёрнет $digest angularjs. Т.е. если раньше мы могли не беспокоиться о лишних digest'ах от активности сторонних либ, т.к. angularjs требует явного пинания CD, то теперь он будет срабатывать на каждый чих.


    Чинится пробраcыванием NgZone сервиса в angularjs (через даунгрейд) и оборачиавния инициализации сторонних либ или родных таймаутов в ngZone.runOutsideAngular. В будущем обещают возможность инициализировать гибрид так, чтобы CD ангуляра и ангуляржса не дёргали друг друга в принципе (ангуляржс будет работать вне зоны ангуляра), и для взаимодействия между разными кусками надо будет явно дёргать CD соответствующего фреймворка.


    downgradeComponent и ChangeDetectionStrategy.OnPush


    Даунгрейженные компоненты некорректно работают с OnPush — при изменении инпутов не дёргается CD на этом компоненте. Код.


    Если закомментировать changeDetection: ChangeDetectionStrategy.OnPush, в angular.component, то счётчик будет обновляться корректно


    Из решений только убрать OnPush с компонента, пока он используется в шаблонах ангуляржс компонентов.


    UI Router


    У нас изначально был ui-router, который работает с новым ангуляром и имеет кучку хаков для работы в гибридном режиме. С ним было немало возни по бутстрапу приложения и проблемам с protractor.


    В итоге пришли к таким хакам инициализации:


    import {NgModuleRef} from "@angular/core";
    import {UpgradeModule} from "@angular/upgrade/static";
    import {UrlService} from "@uirouter/core";
    import {getUIRouter} from "@uirouter/angular-hybrid";
    import {UrlRouterProvider} from "@uirouter/angularjs";
    
    export function deferAndSyncUiRouter(angularjsModule: ng.IModule): void {
      angularjsModule
        .config([ "$urlServiceProvider", ($urlServiceProvider: UrlRouterProvider) => $urlServiceProvider.deferIntercept()])
        // NOTE: uglyhack due to bug with protractor https://github.com/ui-router/angular-hybrid/issues/39
        .run([ "$$angularInjector", $$angularInjector => {
          const url: UrlService = getUIRouter($$angularInjector).urlService;
          url.listen();
          url.sync();
        }]);
    }
    
    export function bootstrapWithUiRouter(platformRef: NgModuleRef<any>, angularjsModule: ng.IModule): void {
      const injector = platformRef.injector;
      const upgradeModule = injector.get(UpgradeModule);
    
      upgradeModule.bootstrap(document.body, [ angularjsModule.name ], { strictDi: true });
    }

    и в main.ts:


    import angular from "angular";
    import {platformBrowserDynamic} from "@angular/platform-browser-dynamic";
    import {setAngularLib} from "@angular/upgrade/static";
    
    import {AppMainOldModule} from "./app.module.main";
    import {deferAndSyncUiRouter, bootstrapWithUiRouter} from "../bootstrap-with-ui-router";
    
    import {AppMainModule} from "./app.module.main.new";
    
    // NOTE: uglyhack https://github.com/angular/angular/issues/16484#issuecomment-298852692
    setAngularLib(angular);
    
    // TODO: remove after upgrade
    deferAndSyncUiRouter(AppMainOldModule);
    
    platformBrowserDynamic()
      .bootstrapModule(AppMainModule)
      // TODO: remove after upgrade
      .then(platformRef => bootstrapWithUiRouter(platformRef, AppMainOldModule));

    Встречаются неочевидные даже по официальной документации роутера места, например, использование angularjs-like инжектов для OnEnter/OnExit хуков в angular части роутинга:


    testBaseOnEnter.$inject = [ "$transition$" ];
    export function testBaseOnEnter(transition: Transition) {
      const roomsService = transition.injector().get<RoomsService>(RoomsService);
      ...
    }
    
    // test page
    {
      name: ROOMS_TEST_STATES.base,
      url: "/test/{hash:[a-z]{8}}?tool&studentId",
      ...
      onEnter: testBaseOnEnter,
    },

    Информацию об этом пришлось добывать через gitter канал ui-router'а, часть её уже внесли в документацию.


    Protractor


    Через протрактор у нас работает куча e2e тестов. Из проблем в гибридном режиме столкнулись только с тем, что совсем отвалился метод waitForAngular. QA команда впиливала какие-то свои хаки, а также попросила нас реализовать meta-тег в хэдере со счётчиком активных апи запросов, чтобы на основе этого понимать, когда основная активность на странице прекратилась.


    Счётчик делали через появившиеся в ng4 HttpClient Interсeptor'ы:


    @Injectable()
    export class PendingApiCallsCounterInterceptor implements HttpInterceptor {
      constructor(
        private pendingApiCallsCounterService: PendingApiCallsCounterService,
      ) {
      }
    
      public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        this.pendingApiCallsCounterService.increment();
    
        return next.handle(req)
          .finally(() => this.pendingApiCallsCounterService.decrement());
      }
    }
    
    @Injectable()
    export class PendingApiCallsCounterService {
      private apiCallsCounter = 0;
      private counterElement: HTMLMetaElement;
    
      constructor() {
        this.counterElement = document.createElement("meta");
        this.counterElement.name = COUNTER_ELEMENT_NAME;
        document.head.appendChild(this.counterElement);
    
        this.updateCounter();
      }
    
      public decrement(): void {
        this.apiCallsCounter -= 1;
    
        this.updateCounter();
      }
    
      public increment(): void {
        this.apiCallsCounter += 1;
    
        this.updateCounter();
      }
    
      private updateCounter(): void {
        this.counterElement.setAttribute("content", this.apiCallsCounter.toString());
      }
    }
    
    @NgModule({
      providers: [
        { provide: HTTP_INTERCEPTORS, useClass: PendingApiCallsCounterInterceptor, multi: true },
        PendingApiCallsCounterService,
      ]
    })
    export class AppModule {
    }

    В окончании этой истории мы делимся новыми конвенциями, которые помогают команде привыкнуть к работе в Angular.

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

    Comments 3

      0
      Первую часть читал, но не покидает ощущение что проще вам было бы с нуля переписывать, чем в этот гибрид влезать
        +1
        В несколько раз дольше, т.к. вся бизнес логика и основная логика в шаблонах при апгрейде сохраняются, 95% кода у нас уже было на TypeScript. Так же полное переписывание не даст возможности вводить новый функционал одновременно с апгрейдом, гибрид это полноценно позволяет.
        0

        Спасибо за статью, тоже апгрейдимся, определенно не раз к этой серии статей обратимся)

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