Angular and debounce

2019-01-02 17:35发布

问题:

In AngularJS I can debounce a model by using ng-model options.

ng-model-options="{ debounce: 1000 }"

How can I debounce a model in Angular? I tried to search for debounce in the docs but I couldn't find anything.

https://angular.io/search/#stq=debounce&stp=1

A solution would be to write my own debounce function, for example:

import {Component, Template, bootstrap} from 'angular2/angular2';

// Annotation section
@Component({
  selector: 'my-app'
})
@Template({
  url: 'app.html'
})
// Component controller
class MyAppComponent {
  constructor() {
    this.firstName = 'Name';
  }

  changed($event, el){
    console.log("changes", this.name, el.value);
    this.name = el.value;
  }

  firstNameChanged($event, first){
    if (this.timeoutId) window.clearTimeout(this.timeoutID);
    this.timeoutID = window.setTimeout(() => {
        this.firstName = first.value;
    }, 250)
  }

}
bootstrap(MyAppComponent);

And my html

<input type=text [value]="firstName" #first (keyup)="firstNameChanged($event, first)">

But I'm looking for a build in function, is there one in Angular?

回答1:

Updated for RC.5

With Angular 2 we can debounce using RxJS operator debounceTime() on a form control's valueChanges observable:

import {Component}   from '@angular/core';
import {FormControl} from '@angular/forms';
import {Observable}  from 'rxjs/Observable';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/throttleTime';
import 'rxjs/add/observable/fromEvent';

@Component({
  selector: 'my-app',
  template: `<input type=text [value]="firstName" [formControl]="firstNameControl">
    <br>{{firstName}}`
})
export class AppComponent {
  firstName        = 'Name';
  firstNameControl = new FormControl();
  formCtrlSub: Subscription;
  resizeSub:   Subscription;
  ngOnInit() {
    // debounce keystroke events
    this.formCtrlSub = this.firstNameControl.valueChanges
      .debounceTime(1000)
      .subscribe(newValue => this.firstName = newValue);
    // throttle resize events
    this.resizeSub = Observable.fromEvent(window, 'resize')
      .throttleTime(200)
      .subscribe(e => {
        console.log('resize event', e);
        this.firstName += '*';  // change something to show it worked
      });
  }
  ngDoCheck() { console.log('change detection'); }
  ngOnDestroy() {
    this.formCtrlSub.unsubscribe();
    this.resizeSub  .unsubscribe();
  }
} 

Plunker

The code above also includes an example of how to throttle window resize events, as asked by @albanx in a comment below.


Although the above code is probably the Angular-way of doing it, it is not efficient. Every keystroke and every resize event, even though they are debounced and throttled, results in change detection running. In other words, debouncing and throttling do not affect how often change detection runs. (I found a GitHub comment by Tobias Bosch that confirms this.) You can see this when you run the plunker and you see how many times ngDoCheck() is being called when you type into the input box or resize the window. (Use the blue "x" button to run the plunker in a separate window to see the resize events.)

A more efficient technique is to create RxJS Observables yourself from the events, outside of Angular's "zone". This way, change detection is not called each time an event fires. Then, in your subscribe callback methods, manually trigger change detection – i.e., you control when change detection is called:

import {Component, NgZone, ChangeDetectorRef, ApplicationRef, 
        ViewChild, ElementRef} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/throttleTime';
import 'rxjs/add/observable/fromEvent';

@Component({
  selector: 'my-app',
  template: `<input #input type=text [value]="firstName">
    <br>{{firstName}}`
})
export class AppComponent {
  firstName = 'Name';
  keyupSub:  Subscription;
  resizeSub: Subscription;
  @ViewChild('input') inputElRef: ElementRef;
  constructor(private ngzone: NgZone, private cdref: ChangeDetectorRef,
    private appref: ApplicationRef) {}
  ngAfterViewInit() {
    this.ngzone.runOutsideAngular( () => {
      this.keyupSub = Observable.fromEvent(this.inputElRef.nativeElement, 'keyup')
        .debounceTime(1000)
        .subscribe(keyboardEvent => {
          this.firstName = keyboardEvent.target.value;
          this.cdref.detectChanges();
        });
      this.resizeSub = Observable.fromEvent(window, 'resize')
        .throttleTime(200)
        .subscribe(e => {
          console.log('resize event', e);
          this.firstName += '*';  // change something to show it worked
          this.cdref.detectChanges();
        });
    });
  }
  ngDoCheck() { console.log('cd'); }
  ngOnDestroy() {
    this.keyupSub .unsubscribe();
    this.resizeSub.unsubscribe();
  }
} 

Plunker

I use ngAfterViewInit() instead of ngOnInit() to ensure that inputElRef is defined.

detectChanges() will run change detection on this component and its children. If you would rather run change detection from the root component (i.e., run a full change detection check) then use ApplicationRef.tick() instead. (I put a call to ApplicationRef.tick() in comments in the plunker.) Note that calling tick() will cause ngDoCheck() to be called.



回答2:

If you don't want to deal with @angular/forms, you can just use an RxJS Subject with change bindings.

view.component.html

<input [ngModel]='model' (ngModelChange)='changed($event)' />

view.component.ts

import { Subject } from 'rxjs/Subject';
import { Component }   from '@angular/core';
import 'rxjs/add/operator/debounceTime';

export class ViewComponent {
    model: string;
    modelChanged: Subject<string> = new Subject<string>();

    constructor() {
        this.modelChanged
            .debounceTime(300) // wait 300ms after the last event before emitting last event
            .distinctUntilChanged() // only emit if value is different from previous value
            .subscribe(model => this.model = model);
    }

    changed(text: string) {
        this.modelChanged.next(text);
    }
}

This does trigger change detection. For a way that doesn't trigger change detection, check out Mark's answer.


Update

.pipe(debounceTime(300), distinctUntilChanged()) is needed for rxjs 6.

Example:

   constructor() {
        this.modelChanged.pipe(
            debounceTime(300), 
            distinctUntilChanged())
            .subscribe(model => this.model = model);
    }


回答3:

Not directly accessible like in angular1 but you can easily play with NgFormControl and RxJS observables:

<input type="text" [ngFormControl]="term"/>

this.items = this.term.valueChanges
  .debounceTime(400)
  .distinctUntilChanged()
  .switchMap(term => this.wikipediaService.search(term));

This blog post explains it clearly: http://blog.thoughtram.io/angular/2016/01/06/taking-advantage-of-observables-in-angular2.html

Here it is for an autocomplete but it works all scenarios.



回答4:

It could be implemented as Directive

import { Directive, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
import { NgControl } from '@angular/forms';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';
import { Subscription } from 'rxjs';

@Directive({
  selector: '[ngModel][onDebounce]',
})
export class DebounceDirective implements OnInit, OnDestroy {
  @Output()
  public onDebounce = new EventEmitter<any>();

  @Input('debounce')
  public debounceTime: number = 300;

  private isFirstChange: boolean = true;
  private subscription: Subscription;

  constructor(public model: NgControl) {
  }

  ngOnInit() {
    this.subscription =
      this.model.valueChanges
        .debounceTime(this.debounceTime)
        .distinctUntilChanged()
        .subscribe(modelValue => {
          if (this.isFirstChange) {
            this.isFirstChange = false;
          } else {
            this.onDebounce.emit(modelValue);
          }
        });
  }

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

}

use it like

<input [(ngModel)]="value" (onDebounce)="doSomethingWhenModelIsChanged($event)">

component sample

import { Component } from "@angular/core";

@Component({
  selector: 'app-sample',
  template: `
<input[(ngModel)]="value" (onDebounce)="doSomethingWhenModelIsChanged($event)">
<input[(ngModel)]="value" (onDebounce)="asyncDoSomethingWhenModelIsChanged($event)">
`
})
export class SampleComponent {
  value: string;

  doSomethingWhenModelIsChanged(value: string): void {
    console.log({ value });
  }

  async asyncDoSomethingWhenModelIsChanged(value: string): Promise<void> {
    return new Promise<void>(resolve => {
      setTimeout(() => {
        console.log('async', { value });
        resolve();
      }, 1000);
    });
  }
} 


回答5:

You can create an RxJS (v.6) Observable that does whatever you like.

view.component.html

<input type="text" (input)="onSearchChange($event.target.value)" />

view.component.ts

import { Observable } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';

export class ViewComponent {
    searchChangeObserver;

  onSearchChange(searchValue: string) {

    if (!this.searchChangeObserver) {
      Observable.create(observer => {
        this.searchChangeObserver = observer;
      }).pipe(debounceTime(300)) // wait 300ms after the last event before emitting last event
        .pipe(distinctUntilChanged()) // only emit if value is different from previous value
        .subscribe(console.log);
    }

    this.searchChangeObserver.next(searchValue);
  }  


}


回答6:

For anyone using lodash, it is extremely easy to debounce any function:

changed = _.debounce(function() {
    console.log("name changed!");
}, 400);

then just throw something like this into your template:

<input [ngModel]="firstName" (ngModelChange)="changed()" />


回答7:

I solved this by writing a debounce decorator. The problem described could be solved by applying the @debounceAccessor to the property's set accessor.

I've also supplied an additional debounce decorator for methods, which can be useful for other occasions.

This makes it very easy to debounce a property or a method. The parameter is the number of milliseconds the debounce should last, 100 ms in the example below.

@debounceAccessor(100)
set myProperty(value) {
  this._myProperty = value;
}


@debounceMethod(100)
myMethod (a, b, c) {
  let d = a + b + c;
  return d;
}

And here's the code for the decorators:

function debounceMethod(ms: number, applyAfterDebounceDelay = false) {

  let timeoutId;

  return function (target: Object, propName: string, descriptor: TypedPropertyDescriptor<any>) {
    let originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
      if (timeoutId) return;
      timeoutId = window.setTimeout(() => {
        if (applyAfterDebounceDelay) {
          originalMethod.apply(this, args);
        }
        timeoutId = null;
      }, ms);

      if (!applyAfterDebounceDelay) {
        return originalMethod.apply(this, args);
      }
    }
  }
}

function debounceAccessor (ms: number) {

  let timeoutId;

  return function (target: Object, propName: string, descriptor: TypedPropertyDescriptor<any>) {
    let originalSetter = descriptor.set;
    descriptor.set = function (...args: any[]) {
      if (timeoutId) return;
      timeoutId = window.setTimeout(() => {
        timeoutId = null;
      }, ms);
      return originalSetter.apply(this, args);
    }
  }
}

I added an additional parameter for the method decorator which let's you trigger the method AFTER the debounce delay. I did that so I could for instance use it when coupled with mouseover or resize events, where I wanted the capturing to occur at the end of the event stream. In this case however, the method won't return a value.



回答8:

We can create a [debounce] directive which overwrites ngModel's default viewToModelUpdate function with an empty one.

Directive Code

@Directive({ selector: '[debounce]' })
export class MyDebounce implements OnInit {
    @Input() delay: number = 300;

    constructor(private elementRef: ElementRef, private model: NgModel) {
    }

    ngOnInit(): void {
        const eventStream = Observable.fromEvent(this.elementRef.nativeElement, 'keyup')
            .map(() => {
                return this.model.value;
            })
            .debounceTime(this.delay);

        this.model.viewToModelUpdate = () => {};

        eventStream.subscribe(input => {
            this.model.viewModel = input;
            this.model.update.emit(input);
        });
    }
}

How to use it

<div class="ui input">
  <input debounce [delay]=500 [(ngModel)]="myData" type="text">
</div>


回答9:

Simple solution would be to create a directive which you can apply to any control.

import { Directive, ElementRef, Input, Renderer, HostListener, Output, EventEmitter } from '@angular/core';
import { NgControl } from '@angular/forms';

@Directive({
    selector: '[ngModel][debounce]',
})
export class Debounce 
{
    @Output()
    public onDebounce = new EventEmitter<any>();

    @Input('debounce')
    public debounceTime: number = 500;


    private modelValue = null;

    constructor(public model: NgControl, el: ElementRef, renderer: Renderer)
    {

    }


    ngOnInit()
    {
         this.modelValue = this.model.value;

         if (!this.modelValue)
         {
            var firstChangeSubs = this.model.valueChanges.subscribe(v =>
            {
               this.modelValue = v;
               firstChangeSubs.unsubscribe()
            });
        }



        this.model.valueChanges
            .debounceTime(this.debounceTime)
            .distinctUntilChanged()
            .subscribe(mv =>
            {
                if (this.modelValue != mv)
                {
                    this.modelValue = mv;
                    this.onDebounce.emit(mv);
                }


            });
    }
}

usage would be

<textarea [ngModel]="somevalue"   
          [debounce]="2000"
          (onDebounce)="somevalue = $event"                               
          rows="3">
</textarea>


回答10:

Since the topic is old, most of the answers don't work on Angular 6.
So here is a short and simple solution for Angular 6 with RxJS.

Import necessary stuff first:

import { Component, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';

Initialize on ngOnInit:

export class MyComponent implements OnInit {
  notesText: string;
  notesModelChanged: Subject<string> = new Subject<string>();

  constructor() { }

  ngOnInit() {
    this.notesModelChanged
      .pipe(
        debounceTime(2000),
        distinctUntilChanged()
      )
      .subscribe(newText => {
        this.notesText = newText;
        console.log(newText);
      });
  }
}

Use this way:

<input [ngModel]='notesText' (ngModelChange)='notesModelChanged.next($event)' />

P.S.: For more complex and efficient solutions you might still want to check other answers.



回答11:

Spent hours on this, hopefully I can save someone else some time. To me the following approach to using debounce on a control is more intuitive and easier to understand for me. It's built on the angular.io docs solution for autocomplete but with the ability for me to intercept the calls without having to depend on tying the data to the DOM.

Plunker

A use case scenario for this might be checking a username after it's typed to see if someone has already taken it, then warning the user.

Note: don't forget, (blur)="function(something.value) might make more sense for you depending on your needs.



回答12:

This is the best solution I have found till now. Updates the ngModelon blur and debounce

import { Directive, Input, Output, EventEmitter,ElementRef } from '@angular/core';
import { NgControl, NgModel } from '@angular/forms';
import 'rxjs/add/operator/debounceTime'; 
import 'rxjs/add/operator/distinctUntilChanged';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/fromEvent';
import 'rxjs/add/operator/map';

@Directive({
    selector: '[ngModel][debounce]',
})
export class DebounceDirective {
    @Output()
    public onDebounce = new EventEmitter<any>();

    @Input('debounce')
    public debounceTime: number = 500;

    private isFirstChange: boolean = true;

    constructor(private elementRef: ElementRef, private model: NgModel) {
    }

    ngOnInit() {
        const eventStream = Observable.fromEvent(this.elementRef.nativeElement, 'keyup')
            .map(() => {
                return this.model.value;
            })
            .debounceTime(this.debounceTime);

        this.model.viewToModelUpdate = () => {};

        eventStream.subscribe(input => {
            this.model.viewModel = input;
            this.model.update.emit(input);
        });
    }
}

as borrowed from https://stackoverflow.com/a/47823960/3955513

Then in HTML:

<input [(ngModel)]="hero.name" 
        [debounce]="3000" 
        (blur)="hero.name = $event.target.value"
        (ngModelChange)="onChange()"
        placeholder="name">

On blur the model is explicitly updated using plain javascript.

Example here: https://stackblitz.com/edit/ng2-debounce-working



回答13:

HTML file:

<input [ngModel]="filterValue"
       (ngModelChange)="filterValue = $event ; search($event)"
        placeholder="Search..."/>

TS file:

timer = null;
time = 250;
  search(searchStr : string) : void {
    clearTimeout(this.timer);
    this.timer = setTimeout(()=>{
      console.log(searchStr);
    }, time)
  }


回答14:

For Reactive Forms and handling under Angular v2(latest) plus v4 look at:

https://github.com/angular/angular/issues/6895#issuecomment-290892514

Hopefully there will be native support for these kinds of things soon...



回答15:

Solution with initialization subscriber directly in event function:

import {Subject} from 'rxjs';
import {debounceTime, distinctUntilChanged} from 'rxjs/operators';

class MyAppComponent {
    searchTermChanged: Subject<string> = new Subject<string>();

    constructor() {
    }

    onFind(event: any) {
        if (this.searchTermChanged.observers.length === 0) {
            this.searchTermChanged.pipe(debounceTime(1000), distinctUntilChanged())
                .subscribe(term => {
                    // your code here
                    console.log(term);
                });
        }
        this.searchTermChanged.next(event);
    }
}

And html:

<input type="text" (input)="onFind($event.target.value)">


标签: