Migrating from AngularJS to Angular: hybrid mode issues and solutions (2/3)


    Transition in hybrid mode is a natural procedure, well prepared and described by the Angular team. However, in practice there are difficulties and gags that have to be addressed on the fly. In today's continuation of our article on migrating to Angular, we will talk about the problems that the Skyeng team has encountered, and share our solutions.


    The first part , the third part .


    Dynamic compilation from a string


    In angularjs, everything is very simple:


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

    And in Angular, not really.


    The first solution is to take the option from the angular, through the JiT compiler. It implies that in assembly production, despite the AoT compilation of static components, a heavy compiler drags on to assemble dynamic templates. It looks something like this:


    // в некотором модуле
    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 | 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
            );
          });
      }
    }

    And everything seems to be relatively good (the thick compiler in the bundle is still leveled by a mountain of other libraries and the code of the project itself, if it is something more than a todo list), but here we specifically entered this problem:



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


    Six seconds to compile one of our exercise slides, albeit a fairly large one. Despite the fact that for three seconds there is an incomprehensible downtime. Judging by the answer in the issue, the situation will not change in the coming months, and we had to look for another solution.


    It also turned out that in this case we cannot use the factories of components used in the slides already compiled during AoT assembly, because There is no way to populate the JiT compiler cache. Such components were essentially compiled two times - on the backend during AoT build and in runtime when compiling the first slide.


    The second solution in haste was the compilation of templates through $compilefrom angularjs (we still have a hybrid and angulars):


    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));
      }
    }

    The angular component used the upgraded version DynamicTemplateComponentfrom the angular, which used the $compileservice to build a template in which all the components were downgraded from the angular. Such a short layer is angular -> angularjs ($ compile) -> angular.


    This option has few problems, for example, the impossibility of injecting components through a component-collector from the angular, but the main thing is that it will not work after the end of the upgrade and cutting out the angular.


    Additional googling and killing people in the gitter of the angular led to the third solution : variations on what is used directly on the off-site of the angular for such a case, namely, inserting a template directly into the DOM and manually initializing all known components on top of the found tags. Code for the link .


    We insert the template that arrived in the DOM as it is, for each known component (get the token CONTENT_COMPONENTSin the service), look for the corresponding DOM nodes and initialize it.


    Of the minuses:


    • we inject injectors a little clumsily for the correct operation of parental injections;
    • a small hack to support content projection with selects (pulled a couple of methods from the @angular/upgrademodule);
    • input only static and only string;
    • full confidence in the HTML file (inserted without processing, because it may contain inline styles and any other indecency from our admin panel);
    • incorrect sequence of hooks for parents and children (first OnInit/AfterViewInitparents, only then OnInit/AfterViewInitchildren).

    But in general, we have a rather nimble way to initialize a dynamic template, which basically solves our task specifically using angular tools and without lags, as with the JiT compiler.


    It would seem that this can be stopped, but for us the problem has not been completely solved due to how the angular works with content projection. We need to initialize the content of some components (by type of spoilers) only under certain conditions, which is impossible when using the usual one ng-content, but ng-templatewe cannot insert due to the peculiarities of the method of assembling the content. In the future, we will look for a more flexible solution, perhaps we will replace the html content with a JSON structure, according to which we will render the slide with the usual angular components taking into account the dynamic show / hide of some content (it will require the use of self-written components instead ng-content).


    The fourth option may be suitable for someone , which will become officially available as a beta with the release of angular 6 - @angular/elements. These are custom elements implemented through an angular. We register by some tag, insert this tag in the DOM in any way, and on it a full-fledged angular component with all the usual functionality is automatically initialized. Of the limitations - interaction with the main application only through events on such an element.


    Information on them is so far available only in the form of several speeches from ng-conferences, articles on these speeches and technical demos:



    The angular site plans immediately, with the first version @angular/elements, to switch to them instead of the current assembly method:



    Change detection


    In the hybrid, there are several unpleasant problems with the work of the CD between the angular and angular, namely:


    AngularJS in Angular Zone


    Immediately after the initialization of the hybrid, we will get a performance drawdown due to the fact that angularjs code will run in the angular zone, and any setTimeout/ setIntervaland other asynchronous actions from the angularjs code and from the thirdparty libraries used will pull the tick of the angular CD, which will pull $digest angularjs. Those. if earlier we could not worry about extra digest from the activity of third-party libs, because angularjs requires explicit kicking of the CD, now it will work for every sneeze.


    It is repaired by forwarding the NgZoneservice in angularjs (via downgrade) and processing initialization of third-party libs or native timeouts in ngZone.runOutsideAngular. In the future, they promise the opportunity to initialize the hybrid so that the angular and angular CDs do not twitch each other in principle (the angular will work outside the angular zone), and for the interaction between different pieces it will be necessary to explicitly pull the CD of the corresponding framework.


    downgradeComponent and ChangeDetectionStrategy.OnPush


    Downgraded components do not work correctly with OnPush- when changing inputs, the CD on this component does not jerk. Code .


    If you comment changeDetection: ChangeDetectionStrategy.OnPush,in angular.component, then the counter will be updated correctly


    From solutions, only remove OnPushfrom the component while it is used in templates of angular components.


    UI Router


    We originally had a ui-router that works with the new hangar and has a bunch of hacks for working in hybrid mode. There was a lot of fuss with him on the bootstrap of the application and problems with the protractor.


    As a result, we came to such initialization hacks:


    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, angularjsModule: ng.IModule): void {
      const injector = platformRef.injector;
      const upgradeModule = injector.get(UpgradeModule);
      upgradeModule.bootstrap(document.body, [ angularjsModule.name ], { strictDi: true });
    }

    and in 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));

    There are some places that are not obvious even according to the official documentation of the router, for example, the use of angularjs-like injections for OnEnter/ OnExithooks in the angular part of routing:


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

    I had to get information about this through the gitter channel of the ui-router, some of it has already been included in the documentation.


    Protractor


    Through the protractor we have a bunch of e2e tests. Of the problems in hybrid mode, faced only with the fact that the method completely fell off waitForAngular. The QA team was digging in some of its hacks, and also asked us to implement the meta tag in the header with the counter of active api requests, so that on this basis we could understand when the main activity on the page stopped.


    The counter was made through the HttpClient Interceptors that appeared in ng4:


    @Injectable()
    export class PendingApiCallsCounterInterceptor implements HttpInterceptor {
      constructor(
        private pendingApiCallsCounterService: PendingApiCallsCounterService,
      ) {
      }
      public intercept(req: HttpRequest, next: HttpHandler): Observable> {
        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 {
    }

    At the end of this story, we share new conventions that help the team get used to working in Angular.


    Also popular now: