Zum Inhalt springen
aviral gupta

// I1.6 · ca. 40 Min. · Aufbau

Projekt: ein Quiz mit Countdown

Sie bauen eine Quiz-Seite, in der jede Frage auf einen Klick oder auf den Countdown wartet, je nachdem, was zuerst kommt: zwei Promises, ein Promise.race und eine Schleife mit await.

Lektion 6 von 6 in I1 Asynchrones JavaScript

Ende des Moduls

Danach können Sie

  • Den Klick auf die Antwort und den Countdown in Promises verwandeln: ask() und countdown()
  • Die Antwort mit Promise.race gegen den Countdown antreten lassen und den Countdown danach stoppen
  • Die Fragen mit await in einer for...of-Schleife nacheinander stellen und die Punktzahl zeigen
  1. Aufwärmen · Aufgabe 1 von 7

    Zum Aufwärmen aus Lektion I1.5. Was gibt dieser Code aus?

    const {promise, resolve} = Promise.withResolvers();
    resolve("a");
    resolve("b");
    console.log(await promise);
  2. Vorhersagen · Aufgabe 2 von 7

    Sagen Sie es vorher, bevor Sie weiterlesen. Niemand antwortet, und der Countdown wird nach 20 ms mit null erledigt. Was gibt dieser Code aus?

    const noAnswer = new Promise(() => {}); // nobody clicks
    const timeUp = new Promise((resolve) => setTimeout(() => resolve(null), 20));
    
    const answer = await Promise.race([noAnswer, timeUp]);
    console.log(answer === null ? "Time's up!" : answer);
  3. Üben · Aufgabe 3 von 7

    Ergänzen Sie die Funktion, die den Countdown stoppt, sodass left nach stop() bei 3 bleibt.

    function countdown(seconds, tick) {
      let left = seconds;
      const id = setInterval(() => (left -= 1), tick);
      return {stop: () => ____(id), left: () => left};
    }
    stop: () =>(id),
  4. Üben · Aufgabe 4 von 7

    Ordnen Sie jedem Teil des Quiz seine Aufgabe zu.

  5. Üben · Aufgabe 5 von 7

    Die Antwort kommt nach 10 ms, der Countdown würde nach 30 ms enden. Was gibt dieser Code aus?

    const ask = () => new Promise((resolve) => setTimeout(() => resolve("reject"), 10));
    const timeUp = () => new Promise((resolve) => setTimeout(() => resolve(null), 30));
    
    const answer = await Promise.race([ask(), timeUp()]);
    console.log(answer === null ? "Time's up!" : answer === "reject" ? "Right!" : "Wrong.");
  6. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. Eine Schleife mit forEach und einem async-Callback. In welcher Reihenfolge werden die Zeilen ausgegeben?

    const questions = ["q1", "q2"];
    questions.forEach(async (question) => {
      console.log("ask", question);
      await null;
      console.log("answered", question);
    });
    console.log("score");
  7. Anwenden · Aufgabe 7 von 7

    Kleine Aufgabe: Erweitern Sie Ihr Quiz. Zeigen Sie "Question 2 of 3" in einem neuen Absatz über jeder Frage und leeren Sie ihn am Ende. Zeigen Sie nach einer falschen Antwort oder bei Zeitablauf auch die richtige Antwort, etwa "Wrong. The answer is reject." oder "Time’s up! The answer is reject.". Behalten Sie "Right!" für eine richtige Antwort bei. Probieren Sie es über einen lokalen Webserver aus.

    Prüfen Sie Ihr Ergebnis anhand dieser Liste

Selbst programmieren

Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.

Ausgearbeitetes Beispiel

Das Quiz von Anfang bis Ende

Die Seite hat eine Frage, ein leeres #answers-Feld, einen Timer, eine Ergebniszeile und einen Start-Button. main.js enthält drei Fragen als Daten. ask() und countdown() geben Promises zurück, und run() lässt sie für jede Frage in einer for...of-Schleife gegeneinander antreten. Am Ende spielt das Skript sein eigenes Quiz, so wie es die Tests der Übungen tun: Es beantwortet die erste Frage richtig, die zweite falsch und lässt bei der dritten die Zeit ablaufen.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

// The script plays its own quiz, the way the exercise tests do.
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const click = (label) => [...answers.querySelectorAll('button')].find((b) => b.textContent === label).click();

startButton.click();
console.log('1:', questionText.textContent, '|', timerText.textContent);
click('Promise.all');
await pause(0);
console.log('->', result.textContent);
click('resolve');
await pause(0);
console.log('->', result.textContent);
console.log('3:', questionText.textContent);
await pause(3200); // no answer
console.log('->', questionText.textContent, '|', result.textContent);

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

Um die Seite auf Ihrem Computer auszuführen, öffnen Sie index.html über einen lokalen Webserver, nicht als Datei: Browser führen Modul-Skripte von file://-Adressen nicht aus. Für die Prüfungen installieren Sie zuerst jsdom (die Befehle unten).

Ausgabe

1: Which combinator waits for every promise to fulfil? | 3 s left
-> Right!
-> Wrong.
3: What does await do with a rejected promise?
-> Done. | Score: 1 of 3
<!-- #result -->
<p id="result">Score: 1 of 3</p>
  • Nach jedem Klick wartet das Skript einen Durchlauf, pause(0): Das race wird entschieden, und run() läuft nach seinem await weiter.
  • Die dritte Frage bekam keine Antwort: Nach 3 Sekunden wurde der Countdown mit null erledigt, race lieferte null, und das war die letzte Runde, die Punktzahl ersetzte also „Time’s up!“.
  • Jede Runde stoppte ihren Countdown, es lief also immer nur ein Intervall.
  • Start war gesperrt, solange das Quiz lief, und am Ende wieder freigegeben.
Ändern und ausführen

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Übungen

Übung 1 von 3

Ein Promise für die Antwort

Vervollständigen Sie ask(question). Es zeigt schon die Frage und ihre Antwort-Buttons. Lassen Sie es ein Promise zurückgeben, das mit dem Text des ersten angeklickten Buttons erledigt wird; geben Sie jedem Button einen Klick-Listener mit {once: true}, der resolve aufruft. Der Startcode gibt nichts zurück, das Quiz rast deshalb durch alle Fragen auf einmal.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Hinweise
  1. Hinweis 1

    Führen Sie die Tests aus: Die erste Frage ist schon weg. Was bekommt run(), wenn es auf eine Funktion wartet, die nichts zurückgibt?

  2. Hinweis 2

    Geben Sie am Ende von ask new Promise((resolve) => { … }) zurück und fügen Sie die Listener darin hinzu.

  3. Hinweis 3

    for (const button of buttons) button.addEventListener('click', () => resolve(button.textContent), { once: true });

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);
Auf dem eigenen Computer ausführen

Installieren Sie ECMAScript 2026 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  // TODO: return a promise that resolves with the text of the first button clicked.
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
  const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
  assert.ok(button, `es gibt keinen Antwort-Button ${label}; die Buttons sind ${JSON.stringify(labels(page))}`);
  button.click();
  await pause(0);
}
async function start() {
  const page = await loadPage();
  page.document.querySelector('#start').click();
  await pause(0);
  return page;
}

test('Start zeigt die erste Frage und ihre drei Antworten', async () => {
  const page = await start();
  assert.equal(text(page, '#question'), 'Which combinator waits for every promise to fulfil?', 'die Frage lautet ' + text(page, '#question'));
  assert.deepEqual(labels(page), ['Promise.race', 'Promise.all', 'Promise.any'], 'die Antwort-Buttons sind ' + JSON.stringify(labels(page)));
});

test('Eine richtige Antwort sagt Right! und zeigt die nächste Frage', async () => {
  const page = await start();
  await answer(page, 'Promise.all');
  assert.equal(text(page, '#result'), 'Right!', 'das Ergebnis ist ' + text(page, '#result'));
  assert.equal(text(page, '#question'), 'Which function makes a new promise fail?', 'die Frage lautet ' + text(page, '#question'));
});

test('Eine falsche Antwort sagt Wrong.', async () => {
  const page = await start();
  await answer(page, 'Promise.race');
  assert.equal(text(page, '#result'), 'Wrong.', 'das Ergebnis ist ' + text(page, '#result'));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

Um die Seite auf Ihrem Computer auszuführen, öffnen Sie index.html über einen lokalen Webserver, nicht als Datei: Browser führen Modul-Skripte von file://-Adressen nicht aus. Für die Prüfungen installieren Sie zuerst jsdom (die Befehle unten).

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

npm install jsdom@28.1.0
node --test
learnrun.js herunterladen

Übung 2 von 3

Die Zeit ist um

Vervollständigen Sie countdown(seconds). Es zeigt schon die verbleibenden Sekunden und zieht jede Sekunde eine ab. Erreicht left die 0, muss es das Intervall mit clearInterval stoppen und das Promise mit null erledigen, damit das race mit "Time’s up!" endet. Der Startcode wird nie erledigt, eine Frage ohne Antwort wartet also ewig, während der Timer unter null zählt.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Hinweise
  1. Hinweis 1

    Führen Sie die Tests aus: Nach 3 Sekunden zeigt der Timer 0 s left und geht dann unter null. Was soll bei 0 geschehen?

  2. Hinweis 2

    Im Callback des Intervalls, nach dem Aktualisieren des Textes: if (left === 0) { … }.

  3. Hinweis 3

    if (left === 0) { clearInterval(id); resolve(null); }

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);
Auf dem eigenen Computer ausführen

Installieren Sie ECMAScript 2026 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    // TODO: at 0, stop the interval and resolve the promise with null.
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
  const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
  assert.ok(button, `es gibt keinen Antwort-Button ${label}; die Buttons sind ${JSON.stringify(labels(page))}`);
  button.click();
  await pause(0);
}
async function start() {
  const page = await loadPage();
  page.document.querySelector('#start').click();
  await pause(0);
  return page;
}

test('Der Timer beginnt bei 3 s left und zeigt eine Sekunde später 2 s left', async () => {
  const page = await start();
  assert.equal(text(page, '#timer'), '3 s left', 'am Anfang zeigt der Timer ' + text(page, '#timer'));
  await pause(1100);
  assert.equal(text(page, '#timer'), '2 s left', 'nach einer Sekunde zeigt der Timer ' + text(page, '#timer'));
});

test('Nach 3 Sekunden ohne Antwort ist die Zeit um, und die nächste Frage erscheint', async () => {
  const page = await start();
  try {
    await pause(3300);
    assert.equal(text(page, '#result'), "Time's up!", 'das Ergebnis ist ' + text(page, '#result'));
    assert.equal(text(page, '#question'), 'Which function makes a new promise fail?', 'die Frage lautet ' + text(page, '#question'));
    assert.equal(text(page, '#timer'), '3 s left', 'der nächste Countdown zeigt ' + text(page, '#timer'));
  } finally {
    page.window.close(); // stops a timer that never ends, so the test run can finish
  }
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

Um die Seite auf Ihrem Computer auszuführen, öffnen Sie index.html über einen lokalen Webserver, nicht als Datei: Browser führen Modul-Skripte von file://-Adressen nicht aus. Für die Prüfungen installieren Sie zuerst jsdom (die Befehle unten).

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

npm install jsdom@28.1.0
node --test
learnrun.js herunterladen

Übung 3 von 3

Eine Frage nach der anderen

Reparieren Sie run(). Der Startcode benutzt questions.forEach mit einem async-Callback, und forEach wartet nicht darauf: Alle drei Fragen werden auf einmal gestellt, und die Punktzahl erscheint sofort. Benutzen Sie eine for...of-Schleife, damit jedes await run() anhält, bis diese Frage entschieden ist. Start muss gesperrt bleiben, bis die Punktzahl angezeigt wird.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Hinweise
  1. Hinweis 1

    Führen Sie die Tests aus: Die Seite zeigt sofort die dritte Frage und die Punktzahl. Wer wartet auf die Promises, die der forEach-Callback zurückgibt?

  2. Hinweis 2

    Ersetzen Sie questions.forEach(async (question) => { … }); durch for (const question of questions) { … } und behalten Sie den Rumpf.

  3. Hinweis 3

    Das await in for...of hält run() selbst an, die Zeilen nach der Schleife laufen also erst nach der letzten Frage.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);
Auf dem eigenen Computer ausführen

Installieren Sie ECMAScript 2026 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  // TODO: ask one question at a time; forEach does not wait for the await inside.
  questions.forEach(async (question) => {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  });
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
  const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
  assert.ok(button, `es gibt keinen Antwort-Button ${label}; die Buttons sind ${JSON.stringify(labels(page))}`);
  button.click();
  await pause(0);
}
async function start() {
  const page = await loadPage();
  page.document.querySelector('#start').click();
  await pause(0);
  return page;
}

test('Start zeigt nur die erste Frage, und Start ist gesperrt', async () => {
  const page = await start();
  assert.equal(text(page, '#question'), 'Which combinator waits for every promise to fulfil?', 'die Frage lautet ' + text(page, '#question'));
  assert.equal(page.document.querySelector('#start').disabled, true, 'Start sollte gesperrt sein, solange das Quiz läuft');
});

test('Drei Antworten ergeben die Punktzahl, und Start funktioniert wieder', async () => {
  const page = await start();
  await answer(page, 'Promise.all');
  await answer(page, 'finally');
  await answer(page, 'It throws');
  assert.equal(text(page, '#question'), 'Done.', 'am Ende lautet die Fragezeile ' + text(page, '#question'));
  assert.equal(text(page, '#result'), 'Score: 2 of 3', 'das Ergebnis ist ' + text(page, '#result'));
  assert.equal(page.document.querySelector('#start').disabled, false, 'Start sollte nach dem Quiz freigegeben sein');
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

Um die Seite auf Ihrem Computer auszuführen, öffnen Sie index.html über einen lokalen Webserver, nicht als Datei: Browser führen Modul-Skripte von file://-Adressen nicht aus. Für die Prüfungen installieren Sie zuerst jsdom (die Befehle unten).

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

npm install jsdom@28.1.0
node --test
learnrun.js herunterladen

Häufige Fehler

Das Countdown-Objekt statt seines Promise antreten lassen

function countdown(ms) {
  const {promise, resolve} = Promise.withResolvers();
  const id = setTimeout(() => resolve(null), ms);
  return {promise, stop: () => clearTimeout(id)};
}
const noAnswer = new Promise(() => {});
const timer = countdown(20);
const answer = await Promise.race([noAnswer, timer]);
timer.stop();
console.log(answer.toLowerCase());

Was Node.js ausgibt

TypeError: answer.toLowerCase is not a function

Warum, und die Lösung

timer ist das Objekt {promise, stop}, kein Promise. race behandelt einen Wert, der kein Promise ist, als schon erfüllt, er gewinnt also sofort, und answer ist dieses Objekt, bevor jemand klicken könnte. Lassen Sie das Promise darin antreten: Promise.race([noAnswer, timer.promise]).

Vergessen, dass die Zeit ablaufen kann

const noAnswer = new Promise(() => {});
const timeUp = new Promise((resolve) => setTimeout(() => resolve(null), 20));
const answer = await Promise.race([noAnswer, timeUp]);
console.log(answer.toLowerCase() === "reject" ? "Right!" : "Wrong.");

Was Node.js ausgibt

TypeError: Cannot read properties of null (reading 'toLowerCase')

Warum, und die Lösung

Antwortet niemand rechtzeitig, gewinnt der Countdown, und answer ist null, und null hat keine Methoden. Prüfen Sie zuerst auf das Timeout: if (answer === null) zeigt "Time’s up!", und vergleichen Sie Antworten erst danach.

Das Ergebnis von withResolvers als Array nehmen

const [promise, resolve] = Promise.withResolvers();
setTimeout(() => resolve(null), 20);
console.log(await promise);

Was Node.js ausgibt

TypeError: object is not iterable (cannot read property Symbol(Symbol.iterator))

Warum, und die Lösung

withResolvers gibt ein Objekt mit den Schlüsseln promise, resolve und reject zurück, und ein Objekt lässt sich nicht mit eckigen Klammern destrukturieren. Nehmen Sie geschweifte Klammern und die genauen Namen: const {promise, resolve} = Promise.withResolvers();

JavaScript im Browser: die eigene Engine Ihres Browsers in einem abgeschotteten Worker. Syntaxfehler findet acorn 8.18.0, MIT. Lizenz und Quellcode

Abschlussquiz

5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.

Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Zwei Promises: die Antwort und die Zeit

ask(question) zeigt die Frage, baut pro Antwortmöglichkeit einen Button und gibt ein Promise zurück, das mit dem Text des ersten angeklickten Buttons erledigt wird: Jeder Button bekommt einen Klick-Listener mit {once: true}, der resolve aufruft. countdown(seconds) zeigt die verbleibenden Sekunden und zieht mit setInterval jede Sekunde eine ab. Sein Promise kommt von Promise.withResolvers, weil resolve im Callback des Intervalls aufgerufen wird: Bei 0 löscht es das Intervall und erledigt mit null. Es gibt außerdem stop() zurück, das clearInterval aufruft.

Antreten lassen, dann aufräumen

await Promise.race([ask(question), timer.promise]) liefert das, was zuerst erledigt ist: den angeklickten Text oder null, wenn die Zeit abgelaufen ist. Das race hält den Verlierer nicht an. Ein zu später Klick erledigt trotzdem sein eigenes Promise, auf das niemand mehr wartet, und das ist harmlos. Ein Countdown, der nie gestoppt wird, tickt weiter und ändert den Timer-Text während der nächsten Frage, rufen Sie also timer.stop() gleich nach dem race auf. clearInterval auf ein bereits beendetes Intervall bewirkt nichts.

Eine Frage nach der anderen

run() ist eine async-Funktion. In einer for...of-Schleife hält jedes await die Schleife an, bis das Rennen um diese Frage entschieden ist, die Fragen kommen also nacheinander, und die Punktzahl erscheint erst nach der letzten. questions.forEach(async (question) => …) funktioniert nicht: forEach ruft den Callback für jede Frage sofort auf und wartet nicht auf die Promises, die er zurückgibt, alle Fragen werden also gleichzeitig gestellt, und die Punktzahl erscheint sofort. Der Start-Button ist gesperrt, solange ein Quiz läuft, damit ein zweiter Klick kein zweites Quiz starten kann.

Quellen

Zuletzt geprüft am 5. Oktober 2026