borisbaer: JS-Code wird zu früh ausgeführt (fetch, async, await)

problematische Seite

Hallo zusammen,

ich habe folgendes script zur Eingabe-Validierung:

document.addEventListener( 'DOMContentLoaded', () => {
	const forms = document.querySelectorAll( 'form' );
	for ( const form of forms ) {
		form.addEventListener( 'submit', event => {
			const errors = [];
			if ( form.name === 'sign-in' ) {
				errors.push( validate( form.username, [ { rule: 'required' } ] ) );
				errors.push( validate( form.password, [ { rule: 'required' } ] ) );
			}
			let skip;
			errors.forEach( ( error, index ) => {
				if ( error && !skip ) {
					form.querySelectorAll( 'input' )[index].focus();
					event.preventDefault();
					skip = true;
				}
			});
		});
	}
});

Nachdem die validate-Funktionen die Werte true (= es liegt ein Fehler vor) oder false (= es liegt kein Fehler vor) zurückgegeben und sie in das Array errors gepusht wurden, prüft der untere Teil des scripts die Werte dieses Arrays. Sind alle false, darf der Submit ganz normal ausgeführt werden, ansonsten erfolgt ein preventDefault und das erste ungültige Feld wird fokussiert.

Was mir jedoch Probleme bereitet, sind die fetch-Funktionen, die ich an zwei anderen Stellen habe: Einmal bei der Validierung bezüglich der Einzigartigkeit der Werte (ob z.B. der Benutzername schon vergeben ist) und einmal bei der Handhabung der Darstellung der Fehlermeldungen.

Die validate-Funktion habe ich als async definiert, denn andernfalls kommt es gar nicht zum preventDefault und der Submit geht jedesmal durch.

Die Error-Handling-Funktion mit der fetch-Funktion sieht folgendermaßen aus:

function manageError( input, rule, condition, errors, tooltip = null, param = null ) {
	fetch( '../pages/user/error-messages.json' )
	.then( response => {
		if ( !response.ok )
			throw `${response.status} ${response.statusText}`; 
		return response.json();
	})
	.then ( ( rules ) => {
		const name = input.name;
		let errorMessage;
		for ( let entry of rules[rule] )
			if ( entry[name] )
				errorMessage = entry[name].replace( '%s', param );
		if ( condition ) {
			errors.push( errorMessage );
			addErrorMessage( input, rule, errorMessage, tooltip );
		} else removeErrorMessage( input, rule, errorMessage, tooltip );
	})
	.catch( error => {
		console.log( error );
		return null;
	});
}

Hier gibt es ebenfalls ein errors-Array, das die Fehlermeldungen sammelt. Dieses Array wird an die übergeordnete validate-Funktion übergeben. Wenn mindestens eine Fehlermeldung vorliegt, dann gibt die validate-Funktion (wie oben beschrieben) ein true oder false aus:

if ( errors.length > 0 )
	return true;
else return false;

Jedenfalls kommt auf der obersten Ebene, nachdem alle validate-Funktionen durch sind, beim ersten errors-Array immer ein Array mit Promises mit dem Wert false heraus. Also: Alles okay, es liegen keine Fehler vor, obwohl Fehlermeldungen angezeigt werden. Trotzdem geht der Submit nicht durch.

Es muss an den fetch-Funktionen und deren Timing liegen. Ich habe versucht, mich in die fetch-Funktion einzulesen mit den Promises, async, await usw., aber ich blicke einfach nicht durch.

Hier kann man sich anschauen, was die Konsole ausgibt, wenn man auf den Submit-Button klickt.

errors[0].then( ( value ) => { console.log( value ) } );

Lange Rede, kurzer Sinn: Ich möchte, dass dieser Teil hier …

let skip;
errors.forEach( ( error, index ) => {
	if ( error && !skip ) {
		form.querySelectorAll( 'input' )[index].focus();
		event.preventDefault();
		skip = true;
	}
});

... erst feuert, wenn die fetch-Funktion alle Werte übergeben hat und ich möchte, dass die validate-Funktion auch wirklich den richtigen true/false-Wert zurückgibt.

Ich wäre sehr dankbar für jede Hilfe.

Grüße
Boris

akzeptierte Antworten

  1. problematische Seite

    Hallo,

    zum allgemeinen Problem kann ich im Augenblick nichts sagen, das habe ich in seiner Komplexität noch nicht ganz erfasst. Aber ein Detail ist mir aufgefallen.

    if ( errors.length > 0 )
    	return true;
    else return false;
    

    Das kann man kürzer und lesbarer schreiben:

    return (errors.length>0);
    

    Einen boolschen Ausdruck auswerten, und wenn er true ergibt, dann true zurückgeben, sonst false - da ist dein if-Statement irgendwie von hinten durch die Brust ins Knie.

    Einen schönen Tag noch
     Martin

    --
    Wie man sich bettet, so schallt es heraus.
    1. problematische Seite

      Hallo Martin,

      Das kann man kürzer und lesbarer schreiben:

      return (errors.length>0);
      

      Einen boolschen Ausdruck auswerten, und wenn er true ergibt, dann true zurückgeben, sonst false - da ist dein if-Statement irgendwie von hinten durch die Brust ins Knie.

      du hast natürlich recht. Habe ich gleich geändert.

      zum allgemeinen Problem kann ich im Augenblick nichts sagen, das habe ich in seiner Komplexität noch nicht ganz erfasst.

      Das ganze Elend kannst du dir hier ansehen. Es fiel mir auch schwer, die Zusammenhänge hier einigermaßen verständlich zu präsentieren.

      Grüße
      Boris

    2. problematische Seite

      @@Der Martin

      Das kann man kürzer und lesbarer schreiben:

      return (errors.length>0);
      

      Oder – wenn man nicht unbedingt true bzw. false braucht, sondern einem truthy bzw. falsy genügt – noch kürzer:

      return errors.length;
      

      Wenn man wirklich einen booleschen Wert haben möchte, kann man auch

      return !!errors.length;
      

      scheiben. Ob das lesbarer ist als der Vergleich gegen 0? Vermutlich nicht.

      🖖 Живіть довго і процвітайте

      --
      „Im Vergleich mit Elon Musk bei Twitter ist ein Elefant im Porzellanladen eine Ballerina.“
      — @Grantscheam auf Twitter
  2. problematische Seite

      		errors.push( validate( form.username, [ { rule: 'required' } ] ) );
      		errors.push( validate( form.password, [ { rule: 'required' } ] ) );
    

    Die validate-Funktion habe ich als async definiert, denn andernfalls kommt es > gar nicht zum preventDefault und der Submit geht jedesmal durch.

    Ersetze den Code oben mal durch errors.push(await validate(form usw.

    1. problematische Seite

      Ersetze den Code oben mal durch errors.push(await validate(form usw.

      Hatte ich versucht, aber scheinbar ist await nur auf den obersten Modulebenen zulässig. Und überhaupt auch nur in einer async function. Letzteres kann ich zwar durch ein ausgliedern des Codes in eine solche bewerkstelligen, aber das Problem mit der obersten Modulebene bleibt.

      Das await vor den EventListener für den Submit zu Stellen hat laut VSCode „keine Auswirkungen auf den Typ dieses Ausdrucks“. Innerhalb des EventListeners scheint jedoch schon mindestens eine Modulebene zu tief zu sein.

      1. problematische Seite

        Ersetze den Code oben mal durch errors.push(await validate(form usw.

        Hatte ich versucht, aber scheinbar ist await nur auf den obersten Modulebenen zulässig. Und überhaupt auch nur in einer async function. Letzteres kann ich zwar durch ein ausgliedern des Codes in eine solche bewerkstelligen, aber das Problem mit der obersten Modulebene bleibt.

        Ich arbeite kaum mit Frontendcode, aber kannst Du die Funktion nicht einfach asynchron machen?

        document.addEventListener( 'DOMContentLoaded', async () => {

        Was meinst Du mit await ist nur auf der obersten Modulebene erlaubt? Das stimmt so nicht.

        1. problematische Seite

          Hallo Random2356,

          das stimmt so nicht, da hast Du recht.

          Aber grundsätzlich sagte die ECMAScript Spec, dass await nur in einer async-Funktion zulässig ist. Das wird erst in der ES2022 Spec aufgehoben, die awaits in einem top-level ECMAScript-Modul zulässt. Die Browserhersteller haben das vor anderthalb Jahren ausgerollt.

          Rolf

          --
          sumpsi - posui - obstruxi
        2. problematische Seite

          Ich arbeite kaum mit Frontendcode, aber kannst Du die Funktion nicht einfach asynchron machen?

          document.addEventListener( 'DOMContentLoaded', async () => {

          Tatsächlich müsste ich das async vor die Funktion des EventHandlers stellen, damit await validate schreiben kann, etwa so: form.addEventListener( 'submit', async event => {

  3. problematische Seite

    Hallo borisbaer,

    du trittst Dir mit der Asynchronität auf die Füße, genau. Sobald Du asynchron prüfst, kannst Du den Submit nur noch abbrechen. Denn Du kannst im Eventhandler für submit nicht auf das Promise-Ergebnis warten - es geht nicht, beim Versuch stündest du Dir in der Taskqueue selbst im Weg.

    Ein Mix aus sync und async macht die Sache richtig gruselig, daher wäre mein Vorschlag, alles async zu machen.

    1. Lass validate grundsätzlich ein Promise zurückgeben. Wenn validate den Fehler synchron ermitteln kann, dann gib eins zurück, das schon resolved ist.

    function validate(...) {
       if (user.isIdiot)
          return Promise.resolve("Du bist ein Idiot!");
       else
          return Promise.resolve(null);
    }
    

    Wenn Du dagegen asynchron prüfen musst, dann lass validate ein Promise zurückgeben, dass zur Fehlermeldung oder null resolved - je nach Prüfergebnis.

    Wie Du das in der Interaktion zwischen validate und manageError machst, ist mir nicht ganz klar (ich mag grad nicht deinen Source studieren), aber da wirst Du in validate ein Promise aus manageError brauchen. Gib einfach das Ergebnis der Promise-Kette aus manageError zurück, das sollte passen. Und mich deucht, dass Du da die Fehlerbehandlung irgendwie in validate und manageError gedoppelt haben könntest. Mit einem Promise, das aus manageError zurückkommt, solltest Du das verbessern können. In validate dürfte dann dies hier stehen - nimm es als Pseudocode:

    function validate(...) {
       
       if ( /* Feld mit asynchroner Prüfung */ ) {
          return manageError(...)
                 .then(error => { // Ist vermutlich ein Text oder Null
                    // Fehler zeigen oder löschen
                    return error;    
                 });
       }
    }
    

    2. Im submit-Handler lässt Du die Validierungen durchlaufen, und dann gehst Du mit Promise.all(errors) her und wartest darauf, dass die Promises sich erfüllen. Dieser Wartevorgang ist immer asynchron, auch wenn schon alle erfüllt sind, d.h. du musst den Submit jetzt erstmal mit preventDefault abbrechen. Die Fortsetzung erfolgt im .then-Handler des Promise.all.

    form.addEventListener( 'submit', event => {
       if ( form.dataset.status == 'ok' )
          return;
    
       const promisedErrors = [];
       if ( form.name === 'sign-in' ) {
          promisedErrors.push( validate( form.username, [ { rule: 'required' } ] ) );
          promisedErrors.push( validate( form.password, [ { rule: 'required' } ] ) );
       }
       event.preventDefault();
    
       Promise.all(promisedErrors)
       .then(errors => {
          errors.forEach( ( error, index ) => {
             if ( error ) {
                form.querySelectorAll( 'input' )[index].focus();
                return;
             }
          });
          form.dataset.status = "ok";
          form.requestSubmit(event.submitter);
       });
    });
    

    Mit elem.dataset greifst Du auf die data-Attribute eines HTML Elements zu. Ich verwende hier data-status, um den Erfolgsstatus der Prüfung zu speichern. Das könnte man natürlich auch irgendwie anders lösen, aber speichern muss man das, denn im then-Handler von Promise.all musst Du, wenn die Prüfungen okay waren, den Submit erneut auslösen. Und dafür nimmt man nicht form.submit(), sondern form.requestSubmit(), weil man damit den Submitter retten kann (also den Button, der den Submit auslöste). Nur löst requestSubmit noch ein weiteres Mal das submit-Event aus, daher ist ein Flag nötig, das nach erfolgreicher Validation dafür sorgt, dass der Submit sofort durchgeht. Danach schickt der Browser das Form neu und das data-Attribut ist wieder weg. Anders wäre es, wenn Du den Submit per Ajax behandelst und das form-Element stehen lässt, dann müsstest Du den Status selbst wieder löschen.

    Soeben gelernt: Promise.all hat die Eigenschaft, nicht länger zu warten, wenn ein Promise rejected. In manageError tust Du das, wenn der fetch fehlschlägt. In dem Fall würdest Du kein Validation-Ergebnis herausbekommen, sondern nur den einen fetch-Fehler. Wenn Du das nicht willst, schau Dir mal Promise.allSettled an (bei MDN, im Selfwiki steht's nicht).

    Rolf

    --
    sumpsi - posui - obstruxi
    1. problematische Seite

      Hallo Rolf,

      du trittst Dir mit der Asynchronität auf die Füße, genau. Sobald Du asynchron prüfst, kannst Du den Submit nur noch abbrechen. Denn Du kannst im Eventhandler für submit nicht auf das Promise-Ergebnis warten - es geht nicht, beim Versuch stündest du Dir in der Taskqueue selbst im Weg.

      das wusste ich nicht, wäre aber sehr wichtig für mich gewesen! 😄

      Ein Mix aus sync und async macht die Sache richtig gruselig, daher wäre mein Vorschlag, alles async zu machen.

      Warum gruselig?

      Wie Du das in der Interaktion zwischen validate und manageError machst, ist mir nicht ganz klar (ich mag grad nicht deinen Source studieren), aber da wirst Du in validate ein Promise aus manageError brauchen. Gib einfach das Ergebnis der Promise-Kette aus manageError zurück, das sollte passen.

      manageError bekommt als Parameter das errors-Array von validate übergeben und gibt gar nichts zurück, sondern pusht einfach eine Fehlermeldung hinein (oder auch nicht). Der Grund dafür ist, dass ich nicht vor jede rule in der validate-Funktion ein push schreiben wollte, sondern dies an einer Stelle erledigt haben wollte. Mehr macht manageError eigentlich gar nicht, außer sich eben noch darum zu kümmern, ob eine Fehlermeldung angezeigt wird oder nicht, und zwar durch die Funktionen addErrorMessage und removeErrorMessage. Auch hier wollte ich mir sparen, dies bei jeder einzelnen rule hinzuschreiben, sondern einfach alles an einer Stelle haben.

      Und mich deucht, dass Du da die Fehlerbehandlung irgendwie in validate und manageError gedoppelt haben könntest. Mit einem Promise, das aus manageError zurückkommt, solltest Du das verbessern können.

      Hm, das weiß ich nicht. Ich sehe jedenfalls keine Dopplung.

      2. Im submit-Handler lässt Du die Validierungen durchlaufen, und dann gehst Du mit Promise.all(errors) her und wartest darauf, dass die Promises sich erfüllen. Dieser Wartevorgang ist immer asynchron, auch wenn schon alle erfüllt sind, d.h. du musst den Submit jetzt erstmal mit preventDefault abbrechen. Die Fortsetzung erfolgt im .then-Handler des Promise.all.

      Das hatte leider nicht funktioniert, da der return im if statement nichts gebracht hat.
      So funktioniert es jedoch:

      let skip;
      Promise.all( errors ).then( errors => {
      	errors.forEach( ( error, index ) => {
      		if ( error && !skip ) {
      			form.querySelectorAll( 'input' )[index].focus();
      			skip = true;
      		}
      	});
      	if ( !skip ) {
      		form.dataset.status = 'ok';
      		form.requestSubmit( e.submitter );
      	}
      });
      

      Mit elem.dataset greifst Du auf die data-Attribute eines HTML Elements zu.

      Damit hatte ich schon gearbeitet.

      Ich verwende hier data-status, um den Erfolgsstatus der Prüfung zu speichern. Das könnte man natürlich auch irgendwie anders lösen, aber speichern muss man das, denn im then-Handler von Promise.all musst Du, wenn die Prüfungen okay waren, den Submit erneut auslösen. Und dafür nimmt man nicht form.submit(), sondern form.requestSubmit(), weil man damit den Submitter retten kann (also den Button, der den Submit auslöste). Nur löst requestSubmit noch ein weiteres Mal das submit-Event aus, daher ist ein Flag nötig, das nach erfolgreicher Validation dafür sorgt, dass der Submit sofort durchgeht. Danach schickt der Browser das Form neu und das data-Attribut ist wieder weg. Anders wäre es, wenn Du den Submit per Ajax behandelst und das form-Element stehen lässt, dann müsstest Du den Status selbst wieder löschen.

      Das hat sehr gut geklappt, danke! Und auch danke für die Erklärung!

      Soeben gelernt: Promise.all hat die Eigenschaft, nicht länger zu warten, wenn ein Promise rejected. In manageError tust Du das, wenn der fetch fehlschlägt. In dem Fall würdest Du kein Validation-Ergebnis herausbekommen, sondern nur den einen fetch-Fehler. Wenn Du das nicht willst, schau Dir mal Promise.allSettled an (bei MDN, im Selfwiki steht's nicht).

      Diese Promise-Geschichte ist sehr abstrakt für mich. Da bräuchte ich deutlich mehr Zeit, um es auch nur ansatzweise zu durchdringen. 😵


      Ich habe nicht verstanden, was du mit deinem Vorschlag „alles async zu machen“ konkret meintest, aber Folgendes habe ich jetzt getan, damit es funktioniert:

      Die validate- und die manageError-Funktion habe ich als async deklariert werden. Vor jedes fetch musste ein await. Ebenso musste vor jeden manageError-Aufruf in der validate-Funktion ein await.

      Ich glaube, das war’s.

      Wahrscheinlich meintest du das nicht, aber zumindest läuft das script jetzt so.

      Grüße
      Boris

  4. problematische Seite

    @@borisbaer

    Nachdem die validate-Funktionen die Werte true (= es liegt ein Fehler vor) oder false (= es liegt kein Fehler vor) zurückgegeben …

    Das ist für einen Fremden nicht verständlich. Auch dein zukünftiges Ich ist ein Fremder.

    Ich würde es bei einer Funktion, die da validate heißt, andersrum erwarten: true bei keinem Fehler; false bei Fehlern.

    Die Funktion ist schlecht benannt. Funktionen, die boolesche Werte zurückgeben, sollten mit is… (bzw. has…) anfangen. isValid wäre eine Bezeichnung, aus der ersichtlich ist, was die Rückgabewerte bedeuten.

    Wenn du bei deinen Werten bleiben willst, wäre isInvalid ein sinnvoller Name. Oder hasInvalidFields.

    🖖 Живіть довго і процвітайте

    --
    „Im Vergleich mit Elon Musk bei Twitter ist ein Elefant im Porzellanladen eine Ballerina.“
    — @Grantscheam auf Twitter
    1. problematische Seite

      Das ist für einen Fremden nicht verständlich. Auch dein zukünftiges Ich ist ein Fremder.

      Du hast recht! Tatsächlich war ich selbst unterbewusst nicht sehr zufrieden damit.

      Ich würde es bei einer Funktion, die da validate heißt, andersrum erwarten: true bei keinem Fehler; false bei Fehlern.

      Die Funktion ist schlecht benannt. Funktionen, die boolesche Werte zurückgeben, sollten mit is… (bzw. has…) anfangen. isValid wäre eine Bezeichnung, aus der ersichtlich ist, was die Rückgabewerte bedeuten.

      Das ist ein sehr guter Hinweis, vielen Dank! Ich habe sie in isValid umbenannt. Jetzt muss ich mich nur noch um die Werte kümmern.

      Wenn du bei deinen Werten bleiben willst, wäre isInvalid ein sinnvoller Name. Oder hasInvalidFields.

      Andersrum gefällt’s mir besser. Auch wenn ich jetzt bei den Werten was ändern muss.

      Danke, Gunnar! 👍