How to preserve state during HMR using Angular

2020-05-19 07:42发布

问题:

In Angular, is there a way to preserve application state after a module has been hot reloaded? Similar to what happens in VueJS:

So far I've gotten HMR to work following several tutorials, but all it does is reload the app without doing an actual page refresh. Fasterthe a full load, yes. But still not where it could be.

Has anyone gotten this to actually work?

PS: it relates to https://github.com/beeman/tutorial-angular-cli-hmr/issues/4

回答1:

I tried seabass's approach above, and had some difficulty getting it to work. I did find it to be very helpful and informational though. Using his ideas, I was able to create a new Angular 6 application and get application state to persist through HMR builds. I created a project on Github so others can pull it down if they want to experiment with it, as this is the best way to learn. Read code comments and check the console log to gain understanding about the order in which things happen, and how they work.

https://github.com/ermcgrat/NgStarter

Clone the project, do an npm install, and then run application with "npm run start". Try changing code in AppComponent and see how the hmr works.

In short however, I was able to achieve state persistence by creating a state service, and leveraging it in my AppModule and hmrBootstrap. First I started with basic HMR functionality, as specified by the Angular CLI team:

https://github.com/angular/angular-cli/wiki/stories-configure-hmr

This will get HMR working, but it won't persist state. I extended the hmr.ts file to save our state when the module is disposed (unloaded). When the new module is evaluated it will read this saved state from the HMR module and inject it into our new module:

hmr.ts

export const hmrBootstrap = (module: any, bootstrap: () => Promise<NgModuleRef<any>>) => {
  module.hot.accept();

  bootstrap().then(mod => {

    // Attach a dispose handler. When this module is replaced, we will first run this code before
    // evaluating the new module. (eg. running main.ts)
    module.hot.dispose(data => {

      if (mod.instance.hmrOnDestroy) {
        mod.instance.hmrOnDestroy(data);
      }

      const appRef: ApplicationRef = mod.injector.get(ApplicationRef);
      const elements = appRef.components.map(c => c.location.nativeElement);
      const makeVisible = createNewHosts(elements);
      mod.destroy();
      makeVisible();
    });

    // Does this module have an hmrOnInit method for us to run?
    // And is there state data from previous unloaded module to initalize?
    let prevData;
    if (module.hot.data && module.hot.data.appState) {
      prevData = module.hot.data.appState;
    }
    if (mod.instance.hmrOnInit && prevData) {
      mod.instance.hmrOnInit(prevData);
    }

  });
};

And here is our AppModule, implementing the hmrOnInit and hmrOnDestroy methods used above. Of note is how the hmrOnInit is restoring application state (if exists) through the state service.

app.module.ts

export class AppModule {

  constructor(private appRef: ApplicationRef, private stateService: AppStateService) { }

  hmrOnInit(prevState: any) {
    if (prevState) {
      this.stateService.saveAppState(prevState);
      // change detection.
      this.appRef.tick();
    }
  }

  hmrOnDestroy(data: any) {
    // Here we will increment our hmrBuilds counter, and then save our state to
    // data (module.hot.data), so that it will be available to the new module.
    const hmrBuilds = this.stateService.getHmrBuilds() + 1;
    this.stateService.saveHmrBuilds(hmrBuilds);
    data.appState = this.stateService.getAppState();
  }
}

And finally the AppStateService. The only thing particularly tricky with this is that we are essentially maintaining the application state in 2 forms. 1 of these is a plain-old-vanilla-object for synchronous access (this is necessary for HMR rebuilds, as async functions in the module dispose can't be guaranteed to finish BEFORE the new module is evaluated). The 2nd is an observable version of our application state, so that various components can easily observe changes/updates to state.

app.state.service.ts

export class AppStateService {

  // attach various component states to this object
  // We maintain an object for synchronous use by the HMR, and an Observable for use by the application and its templates.
  private appState: IAppState = { hmrBuilds: 0 };
  private appStateSubject = new BehaviorSubject<IAppState>({ hmrBuilds: 0 });
  public appState$: Observable<IAppState> = this.appStateSubject.asObservable();

  constructor() { }

  public getAppState() {
    return this.appState;
  }

  public getHmrBuilds(): number {
    return this.appState.hmrBuilds ? this.appState.hmrBuilds : 0;
  }

  public saveAppState(newState: IAppState) {
    this.appState = newState;
    this.appStateSubject.next(newState);
  }

  public saveHmrBuilds(buildNum: number) {
    this.appState.hmrBuilds = buildNum;
  }

}

And finally, any application components can now observe this appState$ and use it within their component code or template.

I would also like to note that this approach to maintaining state between HMR builds essentially leads to a single source of truth. It is in my opinion that a state library such as ngrx would perfectly integrat with something like this.



回答2:

Great question. I finally got webpack hmr to work and preserve angular state, after a lot of headache. My final implementation uses a new environment.hmr.ts as a means to disable hmr in production, or on a testing dev server. I did not implement the suggested .hmr file, instead I left that code in the main.ts

Consider this: You make a change in a file and save. Webpack compiles, magic happens, your Angular app is about to be torn down and swapped with the new changes.

Webpack invokes our function on module['hot']['dispose'] which in turn invokes OnDestroy in the previous app's AppModule class, giving us a chance to save our state.

Then Webpack loads our new app, it bootstraps, and calls OnInit on AppModule, passing it module['hot']['data'] which is our state.

You can read more about what these 'hot' things mean


main.ts


    import { enableProdMode } from '@angular/core';
    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

    import { AppModule } from './app/app.module';
    import { environment } from './environments/environment';

    if (environment.production) {
      enableProdMode();
    }


    function bootstrap(AppModule) {
      return platformBrowserDynamic().bootstrapModule(AppModule)
        .then(MODULE_REF => {

          if (environment.hmr) {
            if (module['hot']) {

              module['hot']['accept']();

              if (MODULE_REF.instance['OnInit']) {
                if (module['hot']['data']) {
                  // Calls OnInit on app.module.ts
                  MODULE_REF.instance['OnInit'](module['hot']['data']);
                }
              }
              if (MODULE_REF.instance['OnStatus']) {
                module['hot']['apply']((status) => {
                  MODULE_REF.instance['OnStatus'](status);
                });
              }
              if (MODULE_REF.instance['OnCheck']) {
                module['hot']['check']((err, outdatedModules) => {
                  MODULE_REF.instance['OnCheck'](err, outdatedModules);
                });
              }
              if (MODULE_REF.instance['OnDecline']) {
                module['hot']['decline']((dependencies) => {
                  MODULE_REF.instance['OnDecline'](dependencies);
                });
              }

              module['hot']['dispose'](store => {
                if (MODULE_REF.instance['OnDestroy']) {
                  // Calls OnDestroy on app.module.ts
                  MODULE_REF.instance['OnDestroy'](store);
                }
                MODULE_REF.destroy();
                if (MODULE_REF.instance['AfterDestroy']) {
                  MODULE_REF.instance['AfterDestroy'](store);
                }
              });
            }
          }

          return MODULE_REF;
        });
    }

    bootstrap(AppModule);


Here we can store the previous app's state in the module['hot']['data'] that is passed in as the store parameter. This same store parameter gets passed into the new app's OnInit(store) enabling us to maintain any state objects within our new app.

app.module.ts


     export class AppModule {
          constructor(private _state: StateService) { }

          OnInit(store) {
            if (store !== undefined) {
              this._state.SetState(store.State);
            }
          }

          OnDestroy(store) {
            store.State = this._state;
          }
        }


Here's my basic state service. You may prefer to use ngrx for your state management here, but I felt it was overkill for my project.

state.service.ts


    import { Injectable } from '@angular/core';
    import { BehaviorSubject } from 'rxjs/BehaviorSubject';

    @Injectable()
    export class StateService {

      public SideNavIsOpen: BehaviorSubject;

      constructor() {
        this.SideNavIsOpen = new BehaviorSubject(false);

      }

      public SetState(_state: StateService) {
        this.SideNavIsOpen = _state.SideNavIsOpen;
      }
    }