Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create RxJS observable stream from Angular2 component template child element event

I am using Angular 2.0.0-rc.4 with RxJS 5.0.0-beta.6.

I am experimenting with the different methods of creating observable streams from events, but am overwhelmed by the options and want to canvass opinion. I appreciate there is no one-size-fits-all solution and there are horses for courses. There are probably other techniques I don't know about or haven't considered.

The Angular 2 component interaction cookbook provides several methods of a parent component interacting with a child component's events. However, only the example of parent and children communicate via a service uses observables and it seems like overkill for most scenarios.

The scenario is that a template's element emits a high volume of events and I want to know, periodically, what the most recent value is.

I use Observable's sampleTime method with a period of 1000ms to monitor the mouse location on a <p> HTML element.

1) This technique uses the ElementRef injected into the component's constructor to access the nativeElement property and query the child elements by tag name.

@Component({
  selector: 'watch-child-events',
  template: `
    <p>Move over me!</p>
    <div *ngFor="let message of messages">{{message}}</div>
  `
})
export class WatchChildEventsComponent implements OnInit {
  messages:string[] = [];

  constructor(private el:ElementRef) {}

  ngOnInit() {
    let p = this.el.nativeElement.getElementsByTagName('p')[0];
    Observable
      .fromEvent(p, 'mousemove')
      .sampleTime(1000)
      .subscribe((e:MouseEvent) => {
        this.messages.push(`${e.type} (${e.x}, ${e.y})`);
      });
  }
}

Consensus opinion seems to frown on this technique because Angular2 provides a sufficient abstraction over the DOM so that you rarely, if ever, need to interact directly with it. But that fromEvent factory method of Observable makes it rather tempting to use and it was the first technique that came to mind.

2) This technique uses an EventEmitter, which is an Observable.

@Component({
  selector: 'watch-child-events',
  template: `
    <p (mousemove)="handle($event)">Move over me!</p>
    <div *ngFor="let message of messages">{{message}}</div>
  `
})
export class WatchChildEventsComponent implements OnInit {
  messages:string[] = [];
  emitter:EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>();

  ngOnInit() {
    this.emitter
      .sampleTime(1000)
      .subscribe((e:MouseEvent) => {
        this.messages.push(`${e.type} (${e.x}, ${e.y})`);
      });
  }

  handle(e:MouseEvent) {
    this.emitter.emit(e);
  }
}

This solution avoids querying the DOM, but event emitters are used to communicate from child to parent, and this event is not for output.

I read here that it shouldn't be assumed that event emitters will be observables in the final release, so this may not be a stable feature to rely on.

3) This technique uses an observable Subject.

@Component({
  selector: 'watch-child-events',
  template: `
    <p (mousemove)="handle($event)">Move over me!</p>
    <div *ngFor="let message of messages">{{message}}</div>
  `
})
export class WatchChildEventsComponent implements OnInit {
  messages:string[] = [];
  subject = new Subject<MouseEvent>();

  ngOnInit() {
    this.subject
      .sampleTime(1000)
      .subscribe((e:MouseEvent) => {
        this.messages.push(`${e.type} (${e.x}, ${e.y})`);
      });
  }

  handle(e:MouseEvent) {
    this.subject.next(e);
  }
}

This solution ticks all the boxes in my opinion without adding much complexity. I could use a ReplaySubject to receive an entire history of published values when I subscribe to it, or just the most recent one, if it exists, with subject = new ReplaySubject<MouseEvent>(1); instead.

4) This technique uses a template reference in conjunction with the @ViewChild decorator.

@Component({
  selector: 'watch-child-events',
  template: `
    <p #p">Move over me!</p>
    <div *ngFor="let message of messages">{{message}}</div>
  `
})
export class WatchChildEventsComponent implements AfterViewInit {
  messages:string[] = [];
  @ViewChild('p') p:ElementRef;

  ngAfterViewInit() {
    Observable
      .fromEvent(this.p.nativeElement, 'mousemove')
      .sampleTime(1000)
      .subscribe((e:MouseEvent) => {
        this.messages.push(`${e.type} (${e.x}, ${e.y})`);
      });
  }
}

While it works, it smells a bit to me. Template references are primarily for component interaction within a template. It also touches the DOM through the nativeElement, uses strings to refer to the event name and the template reference, and uses the AfterViewInit lifecycle hook.

5) I extended the example to use a custom component which manages a Subject and emits an event periodically.

@Component({
  selector: 'child-event-producer',
  template: `
    <p (mousemove)="handle($event)">
      <ng-content></ng-content>
    </p>
  `
})
export class ChildEventProducerComponent {
  @Output() event = new EventEmitter<MouseEvent>();
  subject = new Subject<MouseEvent>();

  constructor() {
    this.subject
      .sampleTime(1000)
      .subscribe((e:MouseEvent) => {
        this.event.emit(e);
      });
  }

  handle(e:MouseEvent) {
    this.subject.next(e);
  }
}

It is used by the parent like this:

@Component({
  selector: 'watch-child-events',
  template: `
    <child-event-producer (event)="handle($event)">
      Move over me!
    </child-event-producer>
    <div *ngFor="let message of messages">{{message}}</div>
  `,
  directives: [ChildEventProducerComponent]
})
export class WatchChildEventsComponent {
  messages:string[] = [];

  handle(e:MouseEvent) {
    this.messages.push(`${e.type} (${e.x}, ${e.y})`);
  }
}

I like this technique; the custom component encapsulates the desired behaviour and makes it easy for the parent to use, but it only communicates up the component tree and can't notify siblings.

6) Contrast that with this technique which simply forwards the event from child to parent.

@Component({
  selector: 'child-event-producer',
  template: `
    <p (mousemove)="handle($event)">
      <ng-content></ng-content>
    </p>
  `
})
export class ChildEventProducerComponent {
  @Output() event = new EventEmitter<MouseEvent>();

  handle(e:MouseEvent) {
    this.event.emit(e);
  }
}

And is wired up in the parent using the @ViewChild decorator either like this:

@Component({
  selector: 'watch-child-events',
  template: `
    <child-event-producer>
      Move over me!
    </child-event-producer>
    <div *ngFor="let message of messages">{{message}}</div>
  `,
  directives: [ChildEventProducerComponent]
})
export class WatchChildEventsComponent implements AfterViewInit {
  messages:string[] = [];
  @ViewChild(ChildEventProducerComponent) child:ChildEventProducerComponent;

  ngAfterViewInit() {
    Observable
      .from(this.child.event)
      .sampleTime(1000)
      .subscribe((e:MouseEvent) => {
        this.messages.push(`${e.type} (${e.x}, ${e.y})`);
      });
  }
}

7) Or like this:

@Component({
  selector: 'watch-child-events',
  template: `
    <child-event-producer (event)="handle($event)">
      Move over me!
    </child-event-producer>
    <div *ngFor="let message of messages">{{message}}</div>
  `,
  directives: [ChildEventProducerComponent]
})
export class WatchChildEventsComponent implements OnInit {
  messages:string[] = [];
  subject = new Subject<MouseEvent>();

  ngOnInit() {
    this.subject
      .sampleTime(1000)
      .subscribe((e:MouseEvent) => {
        this.messages.push(`${e.type} (${e.x}, ${e.y})`);
      });
  }

  handle(e:MouseEvent) {
    this.subject.next(e);
  }
}

using an observable Subject, which is identical to the earlier technique.

8) Finally, if you need to broadcast notifications across the component tree, a shared service seems to be the way to go.

@Injectable()
export class LocationService {
  private source = new ReplaySubject<{x:number;y:number;}>(1);

  stream:Observable<{x:number;y:number;}> = this.source
    .asObservable()
    .sampleTime(1000);

  moveTo(location:{x:number;y:number;}) {
    this.source.next(location);
  }
}

The behaviour is encapsulated in the service. All that is required in the child component is the LocationService injected in the constructor and a call to moveTo in the event handler.

@Component({
  selector: 'child-event-producer',
  template: `
    <p (mousemove)="handle($event)">
      <ng-content></ng-content>
    </p>
  `
})
export class ChildEventProducerComponent {
  constructor(private svc:LocationService) {}

  handle(e:MouseEvent) {
    this.svc.moveTo({x: e.x, y: e.y});
  }
}

Inject the service at the level of the component tree you need to broadcast from.

@Component({
  selector: 'watch-child-events',
  template: `
    <child-event-producer>
      Move over me!
    </child-event-producer>
    <div *ngFor="let message of messages">{{message}}</div>
  `,
  directives: [ChildEventProducerComponent],
  providers: [LocationService]
})
export class WatchChildEventsComponent implements OnInit, OnDestroy {
  messages:string[] = [];
  subscription:Subscription;

  constructor(private svc:LocationService) {}

  ngOnInit() {
    this.subscription = this.svc.stream
      .subscribe((e:{x:number;y:number;}) => {
        this.messages.push(`(${e.x}, ${e.y})`);
      });
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

Not forgetting to unsubscribe when finished with. This solution offers a lot of flexibility at the expense of some complexity.

In conclusion, I would use a Subject internal to the component if there is no need for inter-component communication (3). If I needed to communicate up the component tree I would encapsulate a Subject in the child component and apply the stream operators within the component (5). Otherwise, if I needed maximum flexibility, I would use a service to wrap a stream (8).

like image 437
gwhn Avatar asked Aug 02 '16 14:08

gwhn


1 Answers

In method 6 you can use event binding (scroll down to "Custom Events with EventEmitter") instead of @ViewChild and ngAfterViewInit, which simplifies a lot:

@Component({
  selector: 'watch-child-events',
  template: `
    <child-event-producer (event)="onEvent($event)">
      Move over me!
    </child-event-producer>
    <div *ngFor="let message of messages">{{message}}</div>
  `,
  directives: [ChildEventProducerComponent]
})
export class WatchChildEventsComponent {
  messages:string[] = [];
  onEvent(e) { this.messages.push(`${e.type} (${e.x}, ${e.y})`); }
}
like image 126
Maxim Egorushkin Avatar answered Nov 18 '22 02:11

Maxim Egorushkin