Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 637
smnbbrv Avatar asked Aug 10 '18 13:08

smnbbrv


People also ask

What would be a good use for NgZone service?

Angular 2 runs inside of its own special zone called NgZone and this special zone extends the basic functionality of a zone to facilitate change detection. It is Running inside a zone allows to detect when asynchronous tasks.

What is NgZone run ()?

In those cases, the NgZone service provides a run() method that allows you to execute a function inside the Angular zone. This function, and all asynchronous operations in that function, trigger change detection automatically at the correct time.

What is the use of event emitter?

EventEmitterlink. Use in components with the @Output directive to emit custom events synchronously or asynchronously, and register handlers for those events by subscribing to an instance.


2 Answers

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.

like image 129
Reactgular Avatar answered Oct 22 '22 01:10

Reactgular


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>;
  }
like image 29
smnbbrv Avatar answered Oct 22 '22 01:10

smnbbrv