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

Posted

技术标签:

【中文标题】如何在角度 2 中为异步验证器添加去抖动时间?【英文标题】:How to add debounce time to an async validator in angular 2? 【发布时间】:2016-08-23 11:22:44 【问题描述】:

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

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

【问题讨论】:

我认为不可能...我过去问过这个问题但没有答案:github.com/angular/angular/issues/6895。 @ThierryTemplier 你有办法解决这个问题吗? 【参考方案1】:

这是不可能开箱即用的,因为当input 事件用于触发更新时,验证器会被直接触发。在源代码中看到这一行:

https://github.com/angular/angular/blob/master/modules/angular2/src/common/forms/directives/default_value_accessor.ts#L23

如果你想在这个级别利用去抖动时间,你需要得到一个直接链接到相应 DOM 元素的input 事件的 observable。 Github 中的这个问题可以为您提供上下文:

https://github.com/angular/angular/issues/4062

在您的情况下,一种解决方法是利用 observable 的 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。

【讨论】:

异步验证器工作得很好,但我的其他验证器似乎不起作用,例如*ngIf="(email.touched && email.errors) 不会被触发【参考方案2】:

实现这一点实际上非常简单(不适用于您的情况,但它是一般示例)

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

【讨论】:

我认为这是更好的解决方案。因为@Thierry Templier 的解决方案将延迟所有验证规则,而不仅仅是异步规则。 @n00dl3 的解决方案更优雅,既然 rxjs 已经可用,为什么不使用它来简化更多事情 @BobanStojanovski 这个问题指的是角度 2。我的解决方案仅适用于角度 4+。【参考方案3】:

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

【讨论】:

恕我直言,这是迄今为止“去抖”问题最优雅的解决方案。注意:没有 subscribe() 因为当返回 Observable 而不是 Promise 时,Observable 必须是 cold 问题解决了,我将异步验证器与其他验证器一起发送。 @SamanMohamadi 是的,他也在做同样的事情。为了完成您的评论,Angular 需要传递第三个参数以进行异步验证:this.formBuilder.group( fieldName: [initialValue, [SyncValidators], [AsyncValidators]] ); @ChristianCederquist 是的。另请注意,对于 Angular 6,Observable.timer 已更改为简单的timer,并且switchMap 必须与pipe 运算符一起使用,因此它给出:timer(500).pipe(switchMap(()=&gt;)) 从表面上看,http请求被取消了,因为formControl取消了对observable的订阅。不是因为switchMap。您可以使用 mergeMapConcatMap 获得相同的效果,因为 Timer 只发射一次。【参考方案4】:

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

【讨论】:

【参考方案5】:

我遇到了同样的问题。我想要一个解决输入抖动的解决方案,并且仅在输入更改时才请求后端。

在验证器中使用计时器的所有解决方法都存在问题,即每次击键都会请求后端。它们只会对验证响应进行去抖动。这不是打算做的。您希望输入去抖动和区分,然后才请求后端。

我的解决方案如下(使用反应形式和材料2):

组件

@Component(
    selector: 'prefix-username',
    templateUrl: './username.component.html',
    styleUrls: ['./username.component.css']
)
export class UsernameComponent implements OnInit, OnDestroy 

    usernameControl: FormControl;

    destroyed$ = new Subject<void>(); // observes if component is destroyed

    validated$: Subject<boolean>; // observes if validation responses
    changed$: Subject<string>; // observes changes on username

    constructor(
        private fb: FormBuilder,
        private service: UsernameService,
    ) 
        this.createForm();
    

    ngOnInit() 
        this.changed$ = new Subject<string>();
        this.changed$

            // only take until component destroyed
            .takeUntil(this.destroyed$)

            // at this point the input gets debounced
            .debounceTime(300)

            // only request the backend if changed
            .distinctUntilChanged()

            .subscribe(username => 
                this.service.isUsernameReserved(username)
                    .subscribe(reserved => this.validated$.next(reserved));
            );

        this.validated$ = new Subject<boolean>();
        this.validated$.takeUntil(this.destroyed$); // only take until component not destroyed
    

    ngOnDestroy(): void 
        this.destroyed$.next(); // complete all listening observers
    

    createForm(): void 
        this.usernameControl = this.fb.control(
            '',
            [
                Validators.required,
            ],
            [
                this.usernameValodator()
            ]);
    

    usernameValodator(): AsyncValidatorFn 
        return (c: AbstractControl) => 

            const obs = this.validated$
                // get a new observable
                .asObservable()
                // only take until component destroyed
                .takeUntil(this.destroyed$)
                // only take one item
                .take(1)
                // map the error
                .map(reserved => reserved ? reserved: true : null);

            // fire the changed value of control
            this.changed$.next(c.value);

            return obs;
        
    

模板

<mat-form-field>
    <input
        type="text"
        placeholder="Username"
        matInput
        formControlName="username"
        required/>
    <mat-hint align="end">Your username</mat-hint>
</mat-form-field>
<ng-template ngProjectAs="mat-error" bind-ngIf="usernameControl.invalid && (usernameControl.dirty || usernameControl.touched) && usernameControl.errors.reserved">
    <mat-error>Sorry, you can't use this username</mat-error>
</ng-template>

【讨论】:

这正是我要找的,但是你到底在哪里进行 http 调用呢?我的主要问题是每次按键都会触发后端 api 调用 this.service.isUsernameReserved(username).subscribe(reserved =&gt; this.validated$.next(reserved)); http 调用在服务内。【参考方案6】:

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 怎么来的? rxjs-dev.firebaseapp.com/api/index/function/timer 我真的不记得问题是什么了。【参考方案7】:

这是我使用 rxjs6 的实时 Angular 项目的示例

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

【讨论】:

【参考方案8】:

对于仍然对此主题感兴趣的任何人,请务必在angular 6 document 中注意到这一点:

    他们必须返回一个 Promise 或一个 Observable, 返回的 observable 必须是有限的,这意味着它必须在某个时间点完成。要将无限的 observable 转换为有限的 observable,请通过过滤运算符(例如 first、last、take 或 takeUntil)对 observable 进行管道传输。

请注意上面的第二个要求。

这是AsyncValidatorFn 的实现:

const passwordReapeatValidator: AsyncValidatorFn = (control: FormGroup) => 
  return of(1).pipe(
    delay(1000),
    map(() => 
      const password = control.get('password');
      const passwordRepeat = control.get('passwordRepeat');
      return password &&
        passwordRepeat &&
        password.value === passwordRepeat.value
        ? null
        :  passwordRepeat: true ;
    )
  );
;

【讨论】:

【参考方案9】:

这里的服务返回使用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 状态。【参考方案10】:

保持简单:没有超时、没有延迟、没有自定义 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

【讨论】:

这里可能是最好的解决方案 使用与此类似的代码,但对我来说 debounce/distinctUntilChanged 似乎没有做任何事情 - 验证器在每次按键后立即触发。 看起来不错,但似乎仍然不适用于 Angular 异步验证器 这行不通。验证器正在等待运行其验证器的控件上的 valueChanges 事件,因为 valueChanges 事件已运行。下一个更改将在运行下一个验证之前取消订阅前一个验证器。这可能看起来有效,但在足够慢的过程中会失败,并且总是需要另一个更改来验证最后一个。【参考方案11】:

事情可以简化一点

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

【讨论】:

【参考方案12】:

Angular 9+ asyncValidator w/ debounce

@n00dl3 有正确答案。我喜欢依靠 Angular 代码来取消订阅并通过定时暂停来创建一个新的异步验证器。自编写该答案以来,Angular 和 RxJS API 已经发展,因此我发布了一些更新的代码。

另外,我做了一些改变。 (1) 代码应该报告一个被捕获的错误,而不是将它隐藏在电子邮件地址的匹配下,否则我们会混淆用户。如果网络中断,为什么说电子邮件匹配?! UI 演示代码将区分电子邮件冲突和网络错误。 (2) 验证器应在时间延迟之前捕获控件的值,以防止任何可能的竞争条件。 (3) 使用delay 而不是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 ))));

PS:任何想深入了解 Angular 源代码的人(我强烈推荐它),您都可以找到运行异步验证的 Angular 代码here 和取消订阅的代码here 调用this。都是同一个文件,都在updateValueAndValidity下。

【讨论】:

我真的很喜欢这个答案。计时器为我工作,直到它没有。当下一次验证触发时,它成功取消了 api 请求,但它不应该首先发出 api 请求。到目前为止,此解决方案运行良好。 of(control.value) 起初似乎是任意的(因为它可能是 of(anything)),但它可以将 control.value 的名称更改为电子邮件。跨度> 确实感觉有点武断,在查看 Angular 代码时,没有明显的理由在 switchMap 调用之前更改此值;本练习的重点是仅使用“已解决”的值,并且更改的值将触发重新异步验证。然而,我的防御性程序员说在创建时锁定价值,因为代码永远存在,并且基本假设总是可以改变。 实现了这一点,效果很好。谢谢!这应该是 imo 接受的答案。 所以为了清楚起见,这样做的原因是 Angular 会在值发生变化时在开始新的运行之前取消挂起的异步验证器,对吧?这比尝试像其他几个答案一样尝试去抖动控制值要简单得多。【参考方案13】:

试试计时器。

static verificarUsuario(usuarioservice: UsuarioService) 
    return (control: AbstractControl) => 
        return timer(1000).pipe(
            switchMap(()=>
                usuarioService.buscar(control.value).pipe(
                    map( (res: Usuario) =>  
                        console.log(res);
                        return Object.keys(res).length === 0? null :  mensaje: `El usuario $control.value ya existe` ;
                    )
                )
            )
        )
    

【讨论】:

【参考方案14】:

由于我们正在尝试减少向服务器发出的请求数量,我还建议添加检查以确保仅将有效的电子邮件发送到服务器进行检查

我使用了来自javascript: HTML Form - email validation的简单RegEx

我们还使用timer(1000) 来创建一个在 1 秒后执行的 Observable。

设置了这两项后,我们只检查电子邮件地址是否有效,并且仅在用户输入后 1 秒后检查。 switchMap 如果有新请求,操作员将取消先前的请求


const emailRegExp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`|~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
const emailExists = control =>
  timer(1000).pipe(
    switchMap(() => 
      if (emailRegExp.test(control.value)) 
        return MyService.checkEmailExists(control.value);
      
      return of(false);
    ),
    map(exists => (exists ?  emailExists: true  : null))
  );

然后我们可以将此验证器与Validator.pattern() 函数一起使用

  myForm = this.fb.group(
    email: [ "",  validators: [Validators.pattern(emailRegExp)], asyncValidators: [emailExists] ]
  );

下面是Sample demo on stackblitz

【讨论】:

你使用的正则表达式有点太简单了;它排除了+ 别名和通用***域名,它们都是电子邮件的正常和有效部分,因此foo+bar@example.dance 不起作用。 Angular 已经在Validators.email 中提供了一个电子邮件验证器,您可以在控件的验证器列表中提供它在异步验证器中进行测试。 @doppelgreener 感谢您提供的信息,我已经用更好的正则表达式更新了解决方案

以上是关于如何在角度 2 中为异步验证器添加去抖动时间?的主要内容,如果未能解决你的问题,请参考以下文章

如何在Tomcat 9的rewrite.config文件中为多个角度项目添加多个重写规则

如何在 Xamarin 中为 DatePicker 添加最小日期验证检查

如何在模板驱动的表单中为 ngModelGroup 添加自定义验证

如何在 Azure 构建策略中为 PR 添加状态检查验证

如何在 Spring Security 中为所有请求添加 jwt 身份验证标头?

如何在 Angular 2+ 中添加自定义验证