Angular 2+和防抖技术

206

在AngularJS中,我可以通过使用ng-model选项来防抖模型。

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

我怎样才能在Angular中防抖一个模型?
我尝试在文档中搜索“debounce”,但没有找到任何内容。

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

一种解决方法是编写自己的防抖函数,例如:

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

而我的HTML

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

但是我正在寻找一个内置函数,Angular 中有这样的函数吗?


3
这可能与 https://github.com/angular/angular/issues/1773 有关,显然还没有实现。 - Eric Martinez
16个回答

236

已更新至 RC.5 版本

在 Angular 2 中,我们可以使用 RxJS 操作符 debounceTime() 对表单控件的 valueChanges 可观察对象进行去抖:

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

上述代码还包括了如何限制窗口调整事件的示例,正如@albanx在下面的评论中提到的。


虽然上述代码可能是Angular的做法,但并不高效。即使进行防抖和节流,每个按键输入和每个调整事件都会触发变更检测。换句话说,防抖和节流不会影响变更检测运行的频率。(我在Tobias Bosch的GitHub评论中找到了这个确认。)当您运行Plunker时,可以看到在您输入内容或调整窗口大小时ngDoCheck()被调用的次数。 (使用蓝色的“x”按钮在单独的窗口中运行Plunker,以查看调整事件。)

一种更有效的技术是从事件中自己创建RxJS Observables,而不是在Angular的“zone”之外。 这样,每次事件触发时都不会调用变更检测。 然后,在您的订阅回调方法中,手动触发变更检测,即控制何时调用变更检测:

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

我使用ngAfterViewInit()代替ngOnInit(),以确保inputElRef被定义。

detectChanges()会在该组件及其子组件上运行变更检测。如果您希望从根组件开始运行变更检测(即运行完整的变更检测),则应改用ApplicationRef.tick()。 (我在plunker中将ApplicationRef.tick()调用放在了注释中。)请注意,调用tick()将导致调用ngDoCheck()


2
我认为你应该使用[ngModel]而不是[value],因为[value]不能更新输入值。 - Milad
1
有没有通用的去抖方法(例如在窗口调整大小事件上应用)? - albanx
1
@MarkRajcok 我相信你在回答中提到的 CD 问题已经被 https://github.com/angular/zone.js/pull/843 解决了。 - Jefftopia
2
我们什么时候需要取消订阅以防止内存泄漏? - slanden
1
@slanden 是的,根据 https://netbasal.com/when-to-unsubscribe-in-angular-d61c6b21bad3 的说法,我们应该取消订阅 .fromEvent() 订阅。 - Jon Onstott
显示剩余2条评论

199

如果您不想处理 @angular/forms,您可以使用一个 RxJS Subject 与变更绑定。

view.component.html

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

view.component.ts

import { Subject } from 'rxjs';
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);
    }
}

这会触发变更检测。如果想要避免触发变更检测,可以查看Mark的回答。


更新

.pipe(debounceTime(300), distinctUntilChanged())在rxjs 6中是必需的。

示例:

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

7
我更喜欢这个解决方案!它适用于Angular 2.0.0和rxjs 5.0.0-beta 12。 - user4079725
2
完美地工作,简单明了,没有涉及表单。我使用的是Angular 4.1.3和rxjs 5.1.1。 - fifth
2
.pipe(debounceTime(300), distinctUntilChanged()) 是 rxjs 6 中所需的。 - Icycool
这个解决方案救了我。我在 mat-table 中使用 input.nativeElement 上的 keyUp 事件,但当列数改变时它停止工作了。 - igorepst
1
你认为我们需要在OnDestroy中进行取消订阅或其他操作吗? - Murat Can OĞUZHAN
显示剩余5条评论

65

由于这个主题旧了,大部分答案在Angular 6-13上不适用和/或使用其他库。
因此,这里提供了一个适用于Angular 6+和RxJS的简短而简单的解决方案。

首先导入必要的内容:

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

实现ngOnInitngOnDestroy

export class MyComponent implements OnInit, OnDestroy {
  public notesText: string;
  public notesModelChanged: Subject<string> = new Subject<string>();
  private notesModelChangeSubscription: Subscription

  constructor() { }

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

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

使用这种方式:

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

P.S. 对于更复杂和高效的解决方案,您可能仍然希望查看其他答案。


2
@JustShadow 谢谢你!这对我非常有帮助。 - Niral Munjariya
第一次尝试时这个很完美。但是当我删除搜索文本后,下一个请求会在响应时花费太长时间。 - Sadiksha Gautam
很奇怪,我的这边还是正常工作的。你能否分享更多信息或者开一个新的问题来咨询呢? - Just Shadow
向@JustShadow致敬,感谢您! @SadikshaGautam现在可能已经得到了解决方案,但对于新手来说...您可能需要将debouceTime(2000)从2000毫秒降低到更低的值,例如通常的延迟300毫秒。 - Dynamic Remo
在Angular14中,我遇到了以下错误:error TS2345: 类型“Event”的参数不能赋值给类型“string”。 - jessewolfe
1
我找到了问题 - 你需要有以下代码:import { FormsModule } from '@angular/forms'; 并且在需要使用 ngModel 的 @NgModule 中的 imports: 属性中加入 FormsModule。另外,你也必须在<input>元素中有一个“name”属性。 - jessewolfe

37

它可以作为指令实现

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

}

像这样使用它

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

组件示例

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

1
使用更多的导入,这对我有用:import "rxjs/add/operator/debounceTime"; import "rxjs/add/operator/distinctUntilChanged"; - Sbl
2
这使得它迄今为止是最简单实现应用程序范围的。 - joshcomley
1
isFirstChange 用于在初始化时不发出信号。 - Oleg Polezky
3
适用于Angular 8和rxjs 6.5.2,需要进行以下更改才能使用管道语法:将import 'rxjs/add/operator/debounceTime'; import 'rxjs/add/operator/distinctUntilChanged';更改为import { debounceTime, distinctUntilChanged } from 'rxjs/operators';,并将this.model.valueChanges .debounceTime(this.debounceTime) .distinctUntilChanged()更改为this.model.valueChanges .pipe( debounceTime(this.debounceTime), distinctUntilChanged() ) - kumaheiyama
1
适用于Angular 9和rxjs 6.5.4,根据@kumaheiyama在评论中提到的更改进行操作。只需不要忘记在创建指令的模块中导出指令。并且不要忘记将创建此指令的模块包含在使用它的模块中。 - Filip Savic
显示剩余6条评论

31

虽然不像Angular1那样直接访问,但您可以轻松地使用NgFormControl和RxJS可观察对象进行操作:

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

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

这篇博客文章清楚地解释了它: http://blog.thoughtram.io/angular/2016/01/06/taking-advantage-of-observables-in-angular2.html

这里是一个自动完成的例子,但它适用于所有情况。


但是服务出现了错误,这个不能再次运行。 - Arun Tyagi
我不理解这个例子。[...] 是单向目标绑定。为什么容器可以被通知 valueChanges?难道不应该是类似 (ngFormControl)="..." 这样的东西吗? - phil294

27

您可以创建一个RxJS (v.6) Observable,以执行您想要的任何操作。

view.component.html

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

查看.view.ts文件

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

export class ViewComponent {
    searchChangeObserver;

  onSearchChange(searchValue: string) {

    if (!this.searchChangeObserver) {
      new Observable(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);
  }  


}

谢谢,这很有帮助。不过我认为应该从 rsjs/Rx 导入,按照你写的方式导入时出现了错误...所以在我的情况下,现在是这样的:import { Observable } from 'rxjs/Rx'; - ghiscoding
2
@ghiscoding 这取决于rxjs的版本。在6版本中,它是:import { Observable } from 'rxjs'; - Matthias
谢谢!另外,您可以只使用一个 pipe 调用 pipe(debounceTime(300), distinctUntilChanged()) - al.
1
searchChangeObserver是一个订阅者,因此searchChangeSubscriber将是一个更好的名称。 - Khonsort

16

对于任何使用lodash的人来说,使用debounce来防抖任何函数都非常容易:

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

然后只需将类似以下内容放入您的模板中:

<(input)="changed($event.target.value)" />

3
或者只需 (input)="changed($event.target.value)" - Jamie Kudla
1
感谢您使用 Lodash 进行回答 :) - Vamsi
我相信这仍然会在每次更改时触发Angular变更检测,无论防抖如何。 - AsGoodAsItGets

8
在事件函数中直接初始化订阅者的解决方案:
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);
    }
}

并且 HTML:

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

完全适用于Angular 8 PrimeNG自动完成文本框。非常感谢。 - Jasmin Akther Suma
很棒的回答,继续保持。 - Shashwat Gupta

4
我们可以创建一个名为 [debounce] 的指令,它将覆盖 ngModel 的默认 viewToModelUpdate 函数并替换为空函数。
指令代码
@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);
        });
    }
}

如何使用它

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

Observable的命名空间是什么?我的没有"fromEvent"方法。 - Eduardo Wada

4
我写了一个去抖动装饰器来解决这个问题。应用@debounceAccessor装饰器到属性的set访问器上可以解决所描述的问题。
我还为方法提供了一个额外的去抖动装饰器,这对其他场合也很有用。
这使得去抖动一个属性或方法变得非常容易。参数是去抖持续时间,下面的示例中为100毫秒。
@debounceAccessor(100)
set myProperty(value) {
  this._myProperty = value;
}


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

以下是修饰符的代码:

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

我为方法装饰器添加了一个附加参数,允许您在去抖延迟之后触发该方法。我这样做是为了当与mouseover或resize事件配对使用时,我希望捕获在事件流的末尾发生。但在这种情况下,该方法不会返回值。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接