【问题标题】:How to add debounce time to an async validator in angular 2?如何在角度 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});
          })
      })
    }
}

【问题讨论】:

标签: angular validation asynchronous debouncing


【解决方案1】:

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

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

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

    【讨论】:

    【解决方案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 中注意到这一点:

      1. 他们必须返回一个 Promise 或一个 Observable,
      2. 返回的 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 感谢您提供的信息,我已经用更好的正则表达式更新了解决方案
            猜你喜欢
            • 1970-01-01
            • 2019-03-15
            • 2019-03-23
            • 1970-01-01
            • 2018-08-17
            • 2017-10-26
            • 2020-02-22
            • 2023-03-12
            相关资源
            最近更新 更多