如何在Angular 2中为异步验证器添加去抖时间?

98

这是我的异步验证器,它没有防抖时间,我该如何添加呢?

static emailExist(_signupService:SignupService) {
  return (control:Control) => {
    return new Promise((resolve, reject) => {
      _signupService.checkEmail(control.value)
        .subscribe(
          data => {
            if (data.response.available == true) {
              resolve(null);
            } else {
              resolve({emailExist: true});
            }
          },
          err => {
            resolve({emailExist: true});
          })
      })
    }
}

我认为这是不可能的... 我曾经提出过这个问题,但没有得到答案:https://github.com/angular/angular/issues/6895。 - Thierry Templier
@ThierryTemplier,你有解决那个问题的办法吗? - Chanlito
15个回答

124

Angular 4+,使用Observable.timer(debounceTime)

@izupet的答案是正确的,但值得注意的是,当您使用Observable时,它甚至更简单:

emailAvailability(control: Control) {
    return Observable.timer(500).switchMap(()=>{
      return this._service.checkEmail({email: control.value})
        .mapTo(null)
        .catch(err=>Observable.of({availability: true}));
    });
}

自从Angular 4发布以来,如果一个新值被发送进行检查,Angular会在计时器仍处于暂停状态时取消订阅Observable,因此您实际上不需要自己管理setTimeout/clearTimeout逻辑。

通过使用timer和Angular的异步验证器行为,我们已经重新创建了RxJS debounceTime


10
在我看来,这是针对“去抖问题”最为优雅的解决方案。注意:没有subscribe(),因为当返回一个Observable而不是Promise时,Observable必须是_cold_(指冷的)。 - Bernhard Fürst
1
问题已解决,我在同时发送异步验证器和其他验证器。 - Saman Mohamadi
30
是的。另外需要注意的是,Angular 6中Observable.timer已经被更改为简单的timer,并且必须使用pipe操作符与switchMap一起使用,因此代码应该如下所示:timer(500).pipe(switchMap(()=>{})) - Félix Brunet
1
实际上,HTTP请求被取消是因为FormControl从可观察对象中取消了订阅,而不是因为switchMap。您可以使用mergeMapconcatMap来达到相同的效果,因为Timer只发出一次。 - Félix Brunet
1
最优雅的解决方案 - Rahul Jujarey
显示剩余6条评论

80

保持简单:没有超时,没有延迟,没有自定义的Observable

// assign the async validator to a field
this.cardAccountNumber.setAsyncValidators(this.uniqueCardAccountValidatorFn());
// or like this
new FormControl('', [], [ this.uniqueCardAccountValidator() ]);
// subscribe to control.valueChanges and define pipe
uniqueCardAccountValidatorFn(): AsyncValidatorFn {
  return control => control.valueChanges
    .pipe(
      debounceTime(400),
      distinctUntilChanged(),
      switchMap(value => this.customerService.isCardAccountUnique(value)),
      map((unique: boolean) => (unique ? null : {'cardAccountNumberUniquenessViolated': true})),
      first()); // important to make observable finite
}

13
可能是这里最好的解决方案。 - user3155561
6
使用类似这样的代码,但是对我来说,去抖动(debounce)/去重(distinctUntilChanged)似乎没有任何作用——验证器(validator)会在每个按键后立即触发。 - Rick Strahl
4
看起来不错,但似乎仍无法为Angular异步验证器工作。 - Laszlo Sarvold
9
这样做行不通。验证器正在等待一个值更改事件,因为运行该验证器的控件已经有一个值更改事件了。下一次更改将在运行下一个验证之前取消订阅上一个验证器。这可能看起来有效,但在处理足够缓慢的过程中会失败,并且始终需要另一个更改才能验证最后一个更改。 - Jacob Roberts

59

Angular 9+ 带有防抖动的 asyncValidator

@n00dl3 的答案是正确的。我喜欢依赖 Angular 代码来取消订阅并通过设置一个定时暂停来创建新的异步验证器。自那个答案发布以来,Angular 和 RxJS API 已经发生了变化,因此我发布了一些更新的代码。

同时,我还做了一些更改。(1) 代码应该报告捕获的错误,而不是将其隐藏在电子邮件地址的匹配下面,否则我们会让用户困惑。如果网络出现问题,为什么要说电子邮件匹配成功?UI 展示代码将区分电子邮件冲突和网络错误。(2) 验证器应该在延迟时间之前捕获控件的值,以防止任何可能的竞争条件。(3) 使用 delay 而不是 timer,因为后者会每隔半秒触发一次,并且如果我们的网络很慢,电子邮件检查需要很长时间(一秒钟),那么 timer 将不断重新触发 switchMap,并且调用永远不会完成。

兼容 Angular 9+ 片段:

emailAvailableValidator(control: AbstractControl) {
  return of(control.value).pipe(
    delay(500),
    switchMap((email) => this._service.checkEmail(email).pipe(
      map(isAvail => isAvail ? null : { unavailable: true }),
      catchError(err => { error: err }))));
}

顺便提一下:任何想深入了解Angular源代码的人(我强烈推荐),你可以在这里找到运行异步验证的Angular代码here,以及取消订阅的代码here,后者调用了此处。所有的内容都在updateValueAndValidity下的同一文件中。


1
我真的很喜欢这个答案。计时器一直在工作,直到它停止了。当下一个验证触发时,它成功地取消了API请求,但它本不应该首先进行API请求。这个解决方案目前运行良好。 - Jacob Roberts
1
of(control.value) 一开始似乎是任意的(因为它可以是 of(任何东西)),但它的好处在于能够将 control.value 的名称更改为 email。 - ScubaSteve
它感觉有点武断,在审查Angular代码时,在switchMap调用之前没有明显的原因要更改此值;这个练习的整个重点是仅使用已“确定”的值,而更改的值将触发重新异步验证。然而,我作为一名防御性程序员,认为在创建时锁定该值,因为代码永远存在,底层假设始终可以更改。 - Andrew Philips
1
已经实现并且运行良好。谢谢!在我看来,这应该是被接受的答案。 - Geo242
1
所以,只是为了明确,这个方法能够工作的原因是Angular在值改变时会取消挂起的异步验证器,然后再开始新的运行,对吧?这比其他几个答案想要做的防抖控制值要简单得多。 - Coderer
是的,你说得对。我花了一点时间才理解@n00dl3的想法,因为与RxJS的“debounce”操作符不同,它通过几个代码路径来模拟该行为,因为asyncValidator不像使用switchMap构建的Observable那样。不知道这段代码的历史,我猜测异步验证API早于更干净的Observable switchMap,并且一旦构建完成,开发团队就将其保留为向后兼容性。点击那些源代码链接并四处探索。我花了很多时间学习和理解这个领域。 - Andrew Philips

33

实际上,要实现这一点非常简单(尽管对于您的情况可能不是这样,但这只是一个普通的例子)

private emailTimeout;

emailAvailability(control: Control) {
    clearTimeout(this.emailTimeout);
    return new Promise((resolve, reject) => {
        this.emailTimeout = setTimeout(() => {
            this._service.checkEmail({email: control.value})
                .subscribe(
                    response    => resolve(null),
                    error       => resolve({availability: true}));
        }, 600);
    });
}

3
我认为这是更好的解决方案。因为@Thierry Templier的解决方案会延迟所有验证规则,而不仅仅是异步验证规则。 - aegyed
1
@n00dl3的解决方案更加优雅,而且由于rxjs已经可用,为什么不使用它来进一步简化问题呢? - Boban Stojanovski
@BobanStojanovski 这个问题是关于 Angular 2 的。我的解决方案只适用于 Angular 4+。 - n00dl3

11

由于验证器是直接在使用 input 事件触发更新时触发的,因此默认情况下不可能实现。请查看源代码中的这一行:

如果您想在这个级别利用防抖时间,需要直接与相应DOM元素的 input 事件相关联的可观察对象。Github上的这个问题可以给你提供上下文:

在您的情况下,一个解决方法是实现自定义值访问器,利用可观察对象的 fromEvent 方法。

这里是一个示例:

const DEBOUNCE_INPUT_VALUE_ACCESSOR = new Provider(
  NG_VALUE_ACCESSOR, {useExisting: forwardRef(() => DebounceInputControlValueAccessor), multi: true});

@Directive({
  selector: '[debounceTime]',
  //host: {'(change)': 'doOnChange($event.target)', '(blur)': 'onTouched()'},
  providers: [DEBOUNCE_INPUT_VALUE_ACCESSOR]
})
export class DebounceInputControlValueAccessor implements ControlValueAccessor {
  onChange = (_) => {};
  onTouched = () => {};
  @Input()
  debounceTime:number;

  constructor(private _elementRef: ElementRef, private _renderer:Renderer) {

  }

  ngAfterViewInit() {
    Observable.fromEvent(this._elementRef.nativeElement, 'keyup')
      .debounceTime(this.debounceTime)
      .subscribe((event) => {
        this.onChange(event.target.value);
      });
  }

  writeValue(value: any): void {
    var normalizedValue = isBlank(value) ? '' : value;
    this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', normalizedValue);
  }

  registerOnChange(fn: () => any): void { this.onChange = fn; }
  registerOnTouched(fn: () => any): void { this.onTouched = fn; }
}

然后这样使用:

function validator(ctrl) {
  console.log('validator called');
  console.log(ctrl);
}

@Component({
  selector: 'app'
  template: `
    <form>
      <div>
        <input [debounceTime]="2000" [ngFormControl]="ctrl"/>
      </div>
      value : {{ctrl.value}}
    </form>
  `,
  directives: [ DebounceInputControlValueAccessor ]
})
export class App {
  constructor(private fb:FormBuilder) {
    this.ctrl = new Control('', validator);
  }
}

请查看此 Plunkr:https://plnkr.co/edit/u23ZgaXjAvzFpeScZbpJ?p=preview


1
异步验证器很好用,但我的其他验证器似乎不起作用,例如 *ngIf="(email.touched && email.errors) 没有被触发。 - Chanlito

5

这里有一个返回验证器函数的服务,它使用debounceTime(...)distinctUntilChanged()

@Injectable({
  providedIn: 'root'
})
export class EmailAddressAvailabilityValidatorService {

  constructor(private signupService: SignupService) {}

  debouncedSubject = new Subject<string>();
  validatorSubject = new Subject();

  createValidator() {

    this.debouncedSubject
      .pipe(debounceTime(500), distinctUntilChanged())
      .subscribe(model => {

        this.signupService.checkEmailAddress(model).then(res => {
          if (res.value) {
            this.validatorSubject.next(null)
          } else {
            this.validatorSubject.next({emailTaken: true})
          }
        });
      });

    return (control: AbstractControl) => {

      this.debouncedSubject.next(control.value);

      let prom = new Promise<any>((resolve, reject) => {
        this.validatorSubject.subscribe(
          (result) => resolve(result)
        );
      });

      return prom
    };
  }
}

使用方法:

emailAddress = new FormControl('',
    [Validators.required, Validators.email],
    this.validator.createValidator() // async
);

如果您添加验证器Validators.requiredValidators.email,则只有在输入字符串非空且为有效电子邮件地址时才会进行请求。这样做是为了避免不必要的API调用。


如果 distinctUntilChanged() 失败,我认为 signupService 将不会执行,因此没有任何内容会发出到 validatorSubject,表单将停留在“PENDING”状态。 - funkid

5
一个使用RxJs的替代方案可以是以下内容。
/**
 * From a given remove validation fn, it returns the AsyncValidatorFn
 * @param remoteValidation: The remote validation fn that returns an observable of <ValidationErrors | null>
 * @param debounceMs: The debounce time
 */
debouncedAsyncValidator<TValue>(
  remoteValidation: (v: TValue) => Observable<ValidationErrors | null>,
  remoteError: ValidationErrors = { remote: "Unhandled error occurred." },
  debounceMs = 300
): AsyncValidatorFn {
  const values = new BehaviorSubject<TValue>(null);
  const validity$ = values.pipe(
    debounceTime(debounceMs),
    switchMap(remoteValidation),
    catchError(() => of(remoteError)),
    take(1)
  );

  return (control: AbstractControl) => {
    if (!control.value) return of(null);
    values.next(control.value);
    return validity$;
  };
}

使用方法:

const validator = debouncedAsyncValidator<string>(v => {
  return this.myService.validateMyString(v).pipe(
    map(r => {
      return r.isValid ? { foo: "String not valid" } : null;
    })
  );
});
const control = new FormControl('', null, validator);

4

这是我在实际的Angular项目中使用rxjs6的一个例子

import { ClientApiService } from '../api/api.service';
import { AbstractControl } from '@angular/forms';
import { HttpParams } from '@angular/common/http';
import { map, switchMap } from 'rxjs/operators';
import { of, timer } from 'rxjs/index';

export class ValidateAPI {
  static createValidator(service: ClientApiService, endpoint: string, paramName) {
    return (control: AbstractControl) => {
      if (control.pristine) {
        return of(null);
      }
      const params = new HttpParams({fromString: `${paramName}=${control.value}`});
      return timer(1000).pipe(
        switchMap( () => service.get(endpoint, {params}).pipe(
            map(isExists => isExists ? {valueExists: true} : null)
          )
        )
      );
    };
  }
}

以下是我在响应式表单中使用它的方法

this.form = this.formBuilder.group({
page_url: this.formBuilder.control('', [Validators.required], [ValidateAPI.createValidator(this.apiService, 'meta/check/pageurl', 'pageurl')])
});

2

RxJS 6示例:

import { of, timer } from 'rxjs';
import { catchError, mapTo, switchMap } from 'rxjs/operators';      

validateSomething(control: AbstractControl) {
    return timer(SOME_DEBOUNCE_TIME).pipe(
      switchMap(() => this.someService.check(control.value).pipe(
          // Successful response, set validator to null
          mapTo(null),
          // Set error object on error response
          catchError(() => of({ somethingWring: true }))
        )
      )
    );
  }

需要注意的是,在 RxJS 的后续版本中,timer() 不再是 Observable 中的静态函数。 - AlanObject
怎么会是 @AlanObject 呢?https://rxjs-dev.firebaseapp.com/api/index/function/timer - Dima
我真的记不得问题是什么了。 - AlanObject

2

事情可以简单化一点

export class SomeAsyncValidator {
   static createValidator = (someService: SomeService) => (control: AbstractControl) =>
       timer(500)
           .pipe(
               map(() => control.value),
               switchMap((name) => someService.exists({ name })),
               map(() => ({ nameTaken: true })),
               catchError(() => of(null)));
}

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