Оптимальный повторный вход в ngZone из события EventEmitter

Есть компонент, который инкапсулирует некоторую библиотеку. Чтобы избежать кошмара обнаружения изменений всех слушателей событий этой библиотеки, библиотека выходит за пределы угловой зоны:

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

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

  constructor(private ngZone: NgZone) {}

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

}

Это все довольно ясно и распространено. Теперь давайте добавим событие для запуска действия:

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

}

Проблема в том, что этот излучатель не запускает обнаружение изменений, потому что он срабатывает за пределами зоны. Что тогда возможно, так это повторно войти в зону:

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

}

Наконец, я подхожу к вопросу. это this.ngZone.run вызывает обнаружение изменений, даже если я не прослушивал это событие в родительском компоненте:

<test-component></test-component>

что не нужно, потому что, ну, я не подписался на это событие => нечего обнаруживать.

Что может быть решением этой проблемы?

Для тех, кто интересуется примером из жизни, происхождение вопроса здесь.

1 ответ

Решение

Имейте в виду, что @Output() привязка, которая генерирует значение, по определению является триггером для обнаружения изменений в родительском объекте. Хотя для этой привязки может отсутствовать прослушиватель, в родительском шаблоне может быть логика, которая ссылается на компонент. Может быть через exportAs или @ViewChild запрос. Поэтому, если вы излучаете значение, вы сообщаете родителю, что состояние компонента изменилось. Возможно, в будущем команда Angular изменит это, но сейчас это так и работает.

Если вы хотите обойти обнаружение изменений для этой наблюдаемой, то не используйте @Output декоратор. Удалить декоратор и получить доступ к emtter собственность через exportAs или использовать @ViewChild в родительском компоненте.

Посмотрите, как работают реактивные формы. Директивы для контроля имеют публичные наблюдаемые изменения, которые не используют @Output, Они просто общедоступные, и вы можете подписаться на них.

Поэтому, если вы хотите иметь наблюдаемую информацию, которая не связана с обнаружением изменений, просто сделайте ее доступной для обозрения. Это просто делает это простым. Добавление логики для излучения только при наличии подписчика на @Output затрудняет понимание компонента при последующем чтении исходного кода.

С учетом сказанного, вот как я бы ответил на ваш вопрос, чтобы вы могли использовать @Output() только когда есть подписчик.

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

Если бы я увидел этот исходный код в будущем, то я был бы немного озадачен тем, почему программист сделал это. Это добавляет много дополнительной логики, которая не может четко объяснить проблему, которую решает логика.

Прежде всего, благодаря cgTagответ. Это привело меня в лучшую сторону, которая более читабельна, удобна в использовании и вместо добытчика использует наблюдаемую естественную лень.

Вот хорошо объясненный пример:

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

}

Вот еще один пример, где используется экземпляр библиотеки observable (который генерирует экземпляр библиотеки каждый раз, когда он создается заново, что является довольно распространенным сценарием):

  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>;
  }
Другие вопросы по тегам