Optimal reentering the ngZone from EventEmitter event
Asked Answered
K

2

5

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.

Kigali answered 10/8, 2018 at 13:48 Comment(0)
K
4

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>;
  }
Kigali answered 10/12, 2018 at 10:49 Comment(0)
C
4

Keep in mind that an @Output() binding that emits a value is by definition a trigger for change detection in the parent. While there might not be any listeners for that binding there could be logic in the parent template that references the component. Maybe via the exportAs or a @ViewChild query. So if you are emitting a value you're informing the parent that the component's state has changed. Maybe in the future the Angular team will change this, but that's how it works currently.

If you want to by pass change detection for that observable then don't use the @Output decorator. Remove the decorator and access the emtter property via the exportAs or use a @ViewChild in the parent component.

Look at how reactive forms work. Directives for controls have public observables for changes that don't use @Output. They are just public observables and you can subscribe to them.

So if you want to have an observable that isn't coupled to change detection, then just make it an observable that is public. That just keeps it simple. Adding logic to emit only if there is a subscriber to an @Output makes a component difficult to understand when you read the source code later.

With that said, this is how I would answer your question so that you can use @Output() only when there is a subscriber.

@Component({})
export class TestComponent implements OnInit {

    private lib: Lib;

    constructor(private ngZone: NgZone) {
    }

    @Output()
    public get emitter(): Observable<void> {
        return new Observable((subscriber) => {
            this.initLib();
            this.lib.on('click', () => {
                this.ngZone.run(() => {
                    subscriber.next();
                });
            });
        });
    }

    ngOnInit() {
        this.initLib();
    }

    private initLib() {
        if (!this.lib) {
            this.ngZone.runOutsideAngular(() => {
                this.lib = new Lib();
            });
        }
    }
}

If I saw this source code in the future, then I would be a little confused as to why the programmer did this. It adds a lot of extra logic that doesn't clearly explain the problem the logic is solving.

Clercq answered 10/8, 2018 at 14:37 Comment(3)
that's interesting. Totally forgot about faking the EventEmitter :) I like your solution. It is actually not that ugly if you create a factory with a clarifying name, e.g. @Output() emitter = LazyEventEmitter(/* all required properties for creating observable */), then it becomes quite clear.Kigali
@cgTag Thanks for this answer, that's gold! > smnbbrv Do you have an example code for your LazyEventEmitter decorator idea? I like the approach.Ammann
@Ammann sorry for the late comment, the comment you wrote did not trigger a notification, so I just randomly found it. Looks like this answer was not enough to explain the thing, and actually it is not final / best option in my eyes. So, I prepared another answer which in detail explains how to use a better approach than this original answer proposes. It gives you the option to get .Kigali
K
4

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>;
  }
Kigali answered 10/12, 2018 at 10:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.