Zum Inhalt springen
aviral gupta

// I6.5 · ca. 45 Min. · Aufbau

Projekt: ein Leselisten-Client

In diesem Projekt schreiben Sie client.js: Es spricht mit einem kleinen lokalen Server, sagt der Nutzerin oder dem Nutzer, was gerade passiert, und zeigt nie eine veraltete Liste.

Lektion 5 von 5 in I6 Netzwerkanfragen und Speicher im Client

Ende des Moduls

Danach können Sie

  • Anfragen mit JSON-Body senden und einen Status, der nicht ok ist, in einen ApiError verwandeln
  • Jeden Zustand anzeigen: Loading…, die Liste, eine leere Liste, ein Serverproblem, falsche Eingabe
  • Eine Anfrage abbrechen, die eine neuere ersetzt, bei einer langsamen aufgeben und die beiden unterscheiden
  1. Aufwärmen · Aufgabe 1 von 7

    Aufwärmen aus Lektion I6.3. Das Signal ist abgebrochen, bevor fetch startet. Was gibt das aus?

    const controller = new AbortController();
    controller.abort();
    try {
      await fetch("http://127.0.0.1:9/books", {signal: controller.signal});
    } catch (error) {
      console.log(error.name);
    }
  2. Vorhersagen · Aufgabe 2 von 7

    Raten Sie, bevor Sie weiterlesen. /broken antwortet mit Status 500. Was gibt das aus? (api ist die Adresse des Servers.)

    const response = await fetch(api + "/broken");
    console.log(response.ok, response.status);
  3. Üben · Aufgabe 3 von 7

    Ergänzen Sie die Methode, die aus mehreren Signalen eines macht: Es bricht ab, sobald eines von ihnen abbricht.

    const signal = AbortSignal.____([controller.signal, AbortSignal.timeout(1000)]);
    AbortSignal.([controller.signal
  4. Üben · Aufgabe 4 von 7

    render sammelt der Reihe nach, was die Ansicht zeigt. Was zeigt sie für /broken?

    const load = createListView(api, render);
    await load("/broken");
  5. Üben · Aufgabe 5 von 7

    Der Titel besteht nur aus Leerzeichen. Was gibt das aus?

    try {
      await addBook(api, "   ", "Nobody");
    } catch (error) {
      console.log(error.name, error.status, error.message);
    }
  6. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. Die Nutzerin oder der Nutzer klickt auf Reload, während ein langsames Laden noch läuft. Was zeigt render, der Reihe nach?

    const load = createListView(api, render);
    await Promise.all([load("/slow"), load("/books")]);
  7. Anwenden · Aufgabe 7 von 7

    Mini-Aufgabe: Fügen Sie mit server.js und client.js aus dem durchgespielten Beispiel das Buch Emma von Jane Austen hinzu, markieren Sie es als gelesen (es bekommt die id 2) und versuchen Sie, das Buch mit der id 9 zu markieren, das es nicht gibt: Geben Sie für diesen ApiError mit Status 404 No book with id 9. aus und werfen Sie jeden anderen Fehler weiter. Laden Sie dann die Liste und geben Sie aus, was die Ansicht zuletzt gezeigt hat.

    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

Der Client gegen den lokalen Server

main.js startet server.js mit einem Buch und nutzt client.js: Es lädt die Liste, fügt ein Buch hinzu, markiert eines als gelesen, sendet einen Titel, den der Server ablehnt, lädt eine kaputte Route, gibt bei einer langsamen nach 50 ms auf und startet zum Schluss zwei Ladevorgänge gleichzeitig.

main.js

import {startServer} from "./server.js";
import {createListView, addBook, markRead} from "./client.js";

const server = await startServer([{id: 1, title: "Dune", author: "Frank Herbert", read: false}]);
const api = server.url;
// In a page, render would write into an element; here it prints.
const load = createListView(api, (text) => console.log(text));

await load();
await addBook(api, "Emma", "Jane Austen");
await markRead(api, 1);
await load();

try {
  await addBook(api, "  ");
} catch (error) {
  console.log(`${error.name} ${error.status}: ${error.message}`);
}

await load("/broken");

const impatient = createListView(api, (text) => console.log(text), 50);
await impatient("/slow");

// The second load cancels the first: only one list is shown.
console.log("-- two loads at once");
await Promise.all([load("/slow"), load("/books")]);

await server.close();

client.js

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

Ausführen mit

node main.js

Ausgabe

Loading…
[ ] 1. Dune by Frank Herbert
Loading…
[x] 1. Dune by Frank Herbert
[ ] 2. Emma by Jane Austen
ApiError 400: title is required
Loading…
The server has a problem. Please try again later.
Loading…
The server is too slow. Please try again.
-- two loads at once
Loading…
Loading…
[x] 1. Dune by Frank Herbert
[ ] 2. Emma by Jane Austen
  • Jedes Laden beginnt mit Loading…, die Nutzerin oder der Nutzer starrt also nie auf eine alte Liste, ohne es zu wissen.
  • Der 400 trägt den eigenen Text des Servers; der 500 bekommt stattdessen eine Meldung für die Nutzerin oder den Nutzer.
  • Von den zwei gleichzeitigen Ladevorgängen wird der erste abgebrochen und zeigt nichts: keine veraltete Liste.

Übungen

Übung 1 von 3

Den Status prüfen

main.js ist das client.js aus dem Beispiel; server.js liegt daneben. request gibt jedes JSON zurück, das ankommt, auch für einen Fehlerstatus, ein 500 sieht also wie Daten aus. Sorgen Sie dafür, dass es einen ApiError mit dem Status und dem Fehlertext des Servers wirft, wenn response.ok false ist.

Diese Übung braucht Node.js auf Ihrem Computer (die Browser-Version kann sie nicht ausführen). Dateien und Befehle stehen unten.

Hinweise
  1. Hinweis 1

    fetch wird auch für einen 500 erfüllt; response.ok ist dann false.

  2. Hinweis 2

    Lesen Sie zuerst das JSON: Es enthält den Fehlertext des Servers in data.error.

  3. Hinweis 3

    if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);

Eine Lösung zeigen

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

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}
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

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  return response.json();
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {request, addBook, ApiError} from './main.js';

/** The error a promise rejects with; the test fails when it fulfills. */
async function caught(promise) {
  try {
    await promise;
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

test('a good request returns the JSON', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    assert.deepEqual(await request(server.url + '/books'), [{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  } finally {
    await server.close();
  }
});

test('a 500 becomes an ApiError with the server text', async () => {
  const server = await startServer([]);
  try {
    const error = await caught(request(server.url + '/broken'));
    assert.ok(error instanceof ApiError, `request threw ${error?.name}, not an ApiError`);
    assert.equal(error.status, 500);
    assert.equal(error.message, 'database is down');
  } finally {
    await server.close();
  }
});

test('a refused title becomes an ApiError 400', async () => {
  const server = await startServer([]);
  try {
    const error = await caught(addBook(server.url, '  '));
    assert.equal(error.name, 'ApiError');
    assert.equal(error.status, 400);
    assert.equal(error.message, 'title is required');
  } finally {
    await server.close();
  }
});

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

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

Programm ausführen:

node main.js

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

node --test
learnrun.js herunterladen

Übung 2 von 3

Laden, leer und kaputt

Die Listenansicht zeigt beim Laden nichts, für eine leere Liste eine leere Zeile und für einen Serverfehler die rohe Meldung. Sorgen Sie dafür, dass sie vor jeder Anfrage Loading… rendert, für eine leere Liste No books yet. und für einen ApiError mit einem Status ab 500 The server has a problem. Please try again later.

Diese Übung braucht Node.js auf Ihrem Computer (die Browser-Version kann sie nicht ausführen). Dateien und Befehle stehen unten.

Hinweise
  1. Hinweis 1

    Rufen Sie render("Loading…") direkt vor der Anfrage auf.

  2. Hinweis 2

    books.length === 0 entscheidet zwischen der Liste und No books yet.

  3. Hinweis 3

    Prüfen Sie error instanceof ApiError && error.status >= 500 vor der allgemeinen Meldung.

Eine Lösung zeigen

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

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}
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

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    try {
      const books = await request(api + path, {signal});
      render(books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {createListView} from './main.js';

/** The error a promise rejects with; the test fails when it fulfills. */
async function caught(promise) {
  try {
    await promise;
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

test('a list is shown after Loading…', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text))();
    assert.deepEqual(shown, ['Loading…', '[ ] 1. Dune by Frank Herbert']);
  } finally {
    await server.close();
  }
});

test('an empty list says so', async () => {
  const server = await startServer([]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text))();
    assert.deepEqual(shown, ['Loading…', 'No books yet.']);
  } finally {
    await server.close();
  }
});

test('a 500 asks to try again later', async () => {
  const server = await startServer([]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text))('/broken');
    assert.deepEqual(shown, ['Loading…', 'The server has a problem. Please try again later.']);
  } finally {
    await server.close();
  }
});

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

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

Programm ausführen:

node main.js

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

node --test
learnrun.js herunterladen

Übung 3 von 3

Abbrechen und aufgeben

Die Listenansicht wartet auf jede Anfrage, so lange sie auch dauert, und eine langsame, ältere Antwort kann nach einer neueren eintreffen und wieder angezeigt werden. Sorgen Sie dafür, dass jedes load das vorige abbricht (ignorieren Sie den AbortError) und nach timeoutMs mit The server is too slow. Please try again. aufgibt.

Diese Übung braucht Node.js auf Ihrem Computer (die Browser-Version kann sie nicht ausführen). Dateien und Befehle stehen unten.

Hinweise
  1. Hinweis 1

    Halten Sie einen Controller außerhalb von load; rufen Sie zu Beginn von load controller?.abort() auf und legen Sie einen neuen an.

  2. Hinweis 2

    AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]) liefert ein Signal für beide Fälle.

  3. Hinweis 3

    Kehren Sie in catch für error.name === "AbortError" stillschweigend zurück und rendern Sie für "TimeoutError" die Zu-langsam-Meldung.

Eine Lösung zeigen

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

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}
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

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show.
export function createListView(api, render, timeoutMs = 1000) {
  return async function load(path = "/books") {
    render("Loading…");
    try {
      const books = await request(api + path);
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {createListView} from './main.js';

/** The error a promise rejects with; the test fails when it fulfills. */
async function caught(promise) {
  try {
    await promise;
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

test('a newer load cancels the older one', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    const shown = [];
    const load = createListView(server.url, (text) => shown.push(text));
    await Promise.all([load('/slow'), load('/books')]);
    await new Promise((resolve) => setTimeout(resolve, 400)); // longer than /slow takes
    assert.deepEqual(shown, ['Loading…', 'Loading…', '[ ] 1. Dune by Frank Herbert']);
  } finally {
    await server.close();
  }
});

test('a slow answer times out', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text), 50)('/slow');
    assert.deepEqual(shown, ['Loading…', 'The server is too slow. Please try again.']);
  } finally {
    await server.close();
  }
});

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

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

Programm ausführen:

node main.js

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

node --test
learnrun.js herunterladen

Häufige Fehler

Das JSON eines Fehlerstatus als Daten verwenden

import {startServer} from "./server.js";

const server = await startServer();
try {
  const response = await fetch(server.url + "/broken");
  const books = await response.json();
  console.log(books.map((book) => book.title));
} finally {
  await server.close();
}

Was Node.js ausgibt

TypeError: books.map is not a function

Warum, und die Lösung

fetch wurde erfüllt, weil der Server geantwortet hat, aber mit Status 500 und dem Body {error: "database is down"}, der ein Objekt ist, kein Array. Prüfen Sie response.ok, bevor Sie den Body verwenden, und werfen Sie sonst einen ApiError, wie es request tut.

Ein Timeout wie eine ersetzte Anfrage behandeln

import {startServer} from "./server.js";

const server = await startServer();
try {
  await fetch(server.url + "/slow", {signal: AbortSignal.timeout(50)});
} catch (error) {
  if (error.name !== "AbortError") throw error;
} finally {
  await server.close();
}

Was Node.js ausgibt

The operation was aborted due to timeout

Warum, und die Lösung

Ein Signal von AbortSignal.timeout lehnt mit einem TimeoutError ab, nicht mit einem AbortError, dieses catch wirft ihn also weiter, und das Programm stürzt ab. Ignorieren Sie nur AbortError (eine neuere Anfrage hat diese ersetzt) und sagen Sie der Nutzerin oder dem Nutzer bei einem TimeoutError: The server is too slow. Please try again.

Einen abgebrochenen Controller wiederverwenden

import {startServer} from "./server.js";

const server = await startServer();
const controller = new AbortController();
controller.abort(); // the user cancelled an earlier load
try {
  const response = await fetch(server.url + "/books", {signal: controller.signal});
  console.log(await response.json());
} finally {
  await server.close();
}

Was Node.js ausgibt

This operation was aborted

Warum, und die Lösung

Ein AbortSignal kann nur einmal verwendet werden: Nach abort() wird jedes fetch damit sofort abgelehnt, das neue Laden erreicht den Server also nie. Legen Sie für jede Anfrage einen neuen AbortController an, wie es load mit controller = new AbortController() tut.

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

Eine Funktion für jede Anfrage

request(url, {method, body, signal}) umhüllt fetch. Mit einem body sendet es JSON.stringify(body) und den Header Content-Type: application/json. fetch wird nur abgelehnt, wenn gar keine Antwort ankommt, daher prüft request response.ok selbst und wirft einen ApiError mit dem Status und dem Fehlertext des Servers, etwa 400 und "title is required". addBook und markRead sind je eine Zeile darüber.

Jeder Zustand auf dem Bildschirm

Eine Listenansicht ruft render("Loading…") auf, bevor sie startet, damit die Nutzerin oder der Nutzer sieht, dass etwas passiert. Dann rendert sie die Bücher, "No books yet." für eine leere Liste oder eine Meldung, die zum Fehler passt: Ein 5xx-Status ist das Problem des Servers und einen weiteren Versuch später wert, ein 4xx heißt, dass die Anfrage falsch war. Die Ansicht kennt die Seite nicht: render kann in ein Element schreiben oder ausgeben, wie in dieser Lektion.

Nie eine veraltete Antwort zeigen

Klickt die Nutzerin oder der Nutzer zweimal auf Reload, kann die erste Antwort zuletzt eintreffen und die neuere überschreiben. Darum bricht jedes load den Controller des vorigen ab. AbortSignal.any([controller.signal, AbortSignal.timeout(1000)]) verbindet das mit einem Zeitlimit. Eine ersetzte Anfrage wird mit einem AbortError abgelehnt, den die Ansicht ignoriert; eine langsame mit einem TimeoutError, von dem die Nutzerin oder der Nutzer erfahren soll.

Quellen

Zuletzt geprüft am 5. Oktober 2026