Search code examples
angulartypescriptoutputngzoneangular-event-emitter

Optimal reentering the ngZone from EventEmitter event


There is a component that encapsulates some library. In order to avoid all this library's event listeners' change detection nightmare, the library is scoped outside the angular zone:

@Component({ ... })
export class TestComponent {

  @Output()
  emitter = new EventEmitter<void>();

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
        // ...
    });    
  }

}

That's all quite clear and common. Now let's add the event to emit the action:

@Component({ ... })
export class TestComponent {

  @Output()
  emitter = new EventEmitter<void>();

  private lib: Lib;

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      this.lib = new Lib();
    });

    this.lib.on('click', () => {
      this.emitter.emit();
    });
  }

}

Problem is that this emitter does not trigger the change detection because it is triggered outside the zone. What is possible then is to reenter the zone:

@Component({ ... })
export class TestComponent {

  @Output()
  emitter = new EventEmitter<void>();

  private lib: Lib;

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      this.lib = new Lib();
    });

    this.lib.on('click', () => {
      this.ngZone.run(() => this.emitter.emit());
    });
  }

}

Finally, I come to the question. This this.ngZone.run is forcing the change detection even if I did not listen to this event in the parent component:

<test-component></test-component>

which is not wanted because, well, I did not subscribe to that event => there is nothing to detect.

What could be the solution to that problem?

For those who is interested in the real-life example, the origin of the question is here.


Solution

  • First of all, thanks to cgTag's answer. It guided me into the better direction which is more readable, comfortable to use and instead of getter uses the Observable natural laziness.

    Here is a well-explained example:

    export class Component {
    
      private lib: any;
    
      @Output() event1 = this.createLazyEvent('event1');
    
      @Output() event2 = this.createLazyEvent<{ eventData: string; }>('event2');
    
      constructor(private el: ElementRef, private ngZone: NgZone) { }
    
      // creates an event emitter that binds to the library event
      // only when somebody explicitly calls for it: `<my-component (event1)="..."></my-component>`
      private createLazyEvent<T>(eventName: string): EventEmitter<T> {
        // return an Observable that is treated like EventEmitter
        // because EventEmitter extends Subject, Subject extends Observable
        return new Observable(observer => {
          // this is mostly required because Angular subscribes to the emitter earlier than most of the lifecycle hooks
          // so the chance library is not created yet is quite high
          this.ensureLibraryIsCreated();
    
          // here we bind to the event. Observables are lazy by their nature, and we fully use it here
          // in fact, the event is getting bound only when Observable will be subscribed by Angular
          // and it will be subscribed only when gets called by the ()-binding
          this.lib.on(eventName, (data: T) => this.ngZone.run(() => observer.next(data)));
    
          // important what we return here
          // it is quite useful to unsubscribe from particular events right here
          // so, when Angular will destroy the component, it will also unsubscribe from this Observable
          // and this line will get called
          return () => this.lib.off(eventName);
        }) as EventEmitter<T>;
      }
    
      private ensureLibraryIsCreated() {
        if (!this.lib) {
          this.ngZone.runOutsideAngular(() => this.lib = new MyLib());
        }
      }
    
    }
    

    Here is another example, where the library instance observable is used (which emits the library instance every time it gets re-created, which is quite a common scenario):

      private createLazyEvent<T>(eventName: string): EventEmitter<T> {
        return this.chartInit.pipe(
          switchMap((chart: ECharts) => new Observable(observer => {
            chart.on(eventName, (data: T) => this.ngZone.run(() => observer.next(data)));
            return null; // no need to react on unsubscribe as long as the `dispose()` is called in ngOnDestroy
          }))
        ) as EventEmitter<T>;
      }