Оптимальный повторный вход в 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>;
}