如何添加退出时间的异步验证在角度2?

这是我的异步验证程序,它没有退出时间,我怎么能添加它?

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});
})
})
}
}
45895 次浏览

It's not possible out of the box since the validator is directly triggered when the input event is used to trigger updates. See this line in the source code:

If you want to leverage a debounce time at this level, you need to get an observable directly linked with the input event of the corresponding DOM element. This issue in Github could give you the context:

In your case, a workaround would be to implement a custom value accessor leveraging the fromEvent method of observable.

Here is a sample:

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

And use it this way:

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

See this plunkr: https://plnkr.co/edit/u23ZgaXjAvzFpeScZbpJ?p=preview.

It is actually pretty simple to achieve this (it is not for your case but it is general example)

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

Angular 4+, Using Observable.timer(debounceTime) :

@izupet 's answer is right but it is worth noticing that it is even simpler when you use Observable:

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

Since angular 4 has been released, if a new value is sent for checking, Angular unsubscribes from Observable while it's still paused in the timer, so you don't actually need to manage the setTimeout/clearTimeout logic by yourself.

Using timer and Angular's async validator behavior we have recreated RxJS debounceTime.

an alternative solution with RxJs can be the following.

/**
* 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$;
};
}

Usage:

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

I had the same problem. I wanted a solution for debouncing the input and only request the backend when the input changed.

All workarounds with a timer in the validator have the problem, that they request the backend with every keystroke. They only debounce the validation response. That's not what's intended to do. You want the input to be debounced and distincted and only after that to request the backend.

My solution for that is the following (using reactive forms and material2):

The component

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

The template

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

RxJS 6 example:

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

Here is an example from my live Angular project using 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)
)
)
);
};
}
}

and here is how I use it in my reactive form

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

To anyone still interested in this subject, it's important to notice this in angular 6 document:

  1. They must return a Promise or an Observable,
  2. The observable returned must be finite, meaning it must complete at some point. To convert an infinite observable into a finite one, pipe the observable through a filtering operator such as first, last, take, or takeUntil.

Be careful with the 2nd requirement above.

Here's a AsyncValidatorFn implementation:

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

Here a service that returns a validator function that uses debounceTime(...) and 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
};
}
}

Usage:

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

If you add the validators Validators.required and Validators.email the request will only be made if the input string is non-empty and a valid email address. This should be done to avoid unnecessary API calls.

Keep it simple: no timeout, no delay, no custom 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
}

Things can be simplified a little bit

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

Angular 9+ asyncValidator w/ debounce

@n00dl3 has the correct answer. I love relying on the Angular code to unsubscribe and create a new async validator by throwing in a timed pause. Angular and RxJS APIs have evolved since that answer was written, so I'm posting some updated code.

Also, I made some changes. (1) The code should report a caught error, not hide it under a match on the email address, otherwise we will confuse the user. If the network's down, why say the email matched?! UI presentation code will differentiate between email collision and network error. (2) The validator should capture the control's value prior to the time delay to prevent any possible race conditions. (3) Use delay instead of timer because the latter will fire every half second and if we have a slow network and email check takes a long time (one second), timer will keep refiring the switchMap and the call will never complete.

Angular 9+ compatible fragment:

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: Anyone wanting to dig deeper into the Angular sources (I highly recommend it), you can find the Angular code that runs asynchronous validation here and the code that cancels subscriptions here which calls into this. All the same file and all under updateValueAndValidity.

Try with timer.

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

Since we are trying to reduce the number of request we are making to the server, I would also recommend adding a check to ensure only valid emails are sent to the server for checking

I have used a simple RegEx from JavaScript: HTML Form - email validation

We are also using timer(1000) to create an Observable that executes after 1s.

With this two items set up, we only check an email address if it is valid and only after 1s after user input. switchMap operator will cancel previous request if a new request is made


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

We can then use this validator with the Validator.pattern() function

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

Below is a Sample demo on stackblitz

what @Pavel says is a good solution, but if the form has a previous value, it should be something like this...

private checkEmailAvailabilityValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors> =>
control.value
? of(control.value).pipe(
delay(400),
distinctUntilChanged(),
switchMap(() => this.professionalWorkersService.checkEmailAvailability(control.value, this.workerId)),
map(unique => (unique ? {} : { unavailableEmail: true }))
)
: of();
}