mitmario.dev

Synthese: die Stoppuhr

JavaScript Im Browser 4 Min Lesezeit 3 BeispieleLektion 7 von 8

Zeit für ein Stück, das alles aus diesem Abschnitt zusammenbringt: eine Stoppuhr mit Start, Pause, Weiter und Zurücksetzen.

Das klingt nach einer Fingerübung und hat drei Stellen, an denen fast jeder beim ersten Mal danebengreift.

Zählen ist nicht rechnen

Zählen gegen rechnen
const gezaehltFeld = document.getElementById("gezaehlt");
const gerechnetFeld = document.getElementById("gerechnet");
const abweichungFeld = document.getElementById("abweichung");

const start = Date.now();
let gezaehlt = 0;

setInterval(() => {
  // So nicht: bei jedem Durchlauf 10 dazuzählen.
  gezaehlt = gezaehlt + 10;

  // So: die Differenz zum Startzeitpunkt rechnen.
  const gerechnet = Date.now() - start;

  gezaehltFeld.textContent = String(gezaehlt);
  gerechnetFeld.textContent = String(gerechnet);
  abweichungFeld.textContent = String(gerechnet - gezaehlt);
}, 10);

Der naheliegende Ansatz für eine Stoppuhr ist: ein Intervall alle zehn Millisekunden, und bei jedem Durchlauf zehn dazuzählen.

Das geht schief, und zwar aus dem Grund, der in Lektion 15.1 stand: Die Zeitangabe ist ein Mindestwert. Jeder Durchlauf kommt ein bisschen später als bestellt, und diese Verspätungen addieren sich. Nach einer Minute liegt so eine Uhr sichtbar daneben, im Hintergrundtab noch viel weiter.

Der richtige Weg ist, sich den Startzeitpunkt zu merken und bei jedem Durchlauf die Differenz zu rechnen:

Date.now() - start ist immer korrekt, egal wie unpünktlich das Intervall ist. Das Intervall bestimmt dann nur noch, wie oft neu gezeichnet wird, und nicht mehr, welche Zeit angezeigt wird. Ob es alle 10 oder alle 50 Millisekunden feuert, ändert an der Uhr nichts, nur an der Flüssigkeit der Anzeige.

Das erste Beispiel zeigt beide nebeneinander mit ihrer Abweichung. Lass es eine halbe Minute laufen.

Pause braucht ein zweites Feld

Eine Uhr, die nur laufen kann, braucht einen Startzeitpunkt. Eine Uhr mit Pause braucht zwei Zahlen:

  • gestartet: der Zeitpunkt, an dem zuletzt auf Start gedrückt wurde.
  • angesammelt: die Zeit, die vor der letzten Pause schon zusammengekommen war.

Die verstrichene Zeit ist dann angesammelt plus, falls die Uhr gerade läuft, Date.now() - gestartet.

Beim Pausieren rechnest du den laufenden Teil in angesammelt hinein und stoppst das Intervall. Beim Weiterlaufen setzt du gestartet neu und startest das Intervall wieder. Beim Zurücksetzen sind beide Zahlen null.

Wer versucht, das mit einer einzigen Zahl zu lösen, landet früher oder später bei einer Uhr, die nach der Pause die Pausenzeit mitzählt.

Zustand ändern, dann neu zeichnen

Zustand ändern, dann neu zeichnen
const anzeige = document.getElementById("anzeige");

// Der ganze Zustand steht an einer Stelle.
let wert = 0;

// Eine Funktion bringt die Anzeige auf den Stand. Nur sie
// fasst das DOM an.
function zeichnen() {
  anzeige.textContent = String(wert);
}

// Jeder Handler ändert nur den Zustand und zeichnet neu.
document.getElementById("plus").addEventListener("click", () => {
  wert = wert + 1;
  zeichnen();
});

document.getElementById("mal-zwei").addEventListener("click", () => {
  wert = wert * 2;
  zeichnen();
});

document.getElementById("zurueck").addEventListener("click", () => {
  wert = 0;
  zeichnen();
});

zeichnen();

Dasselbe Muster wie beim Zähler in Lektion 12.8, und hier trägt es noch deutlicher.

Jeder Handler tut genau zwei Dinge: Er ändert den Zustand, und er ruft eine Funktion auf, die die Anzeige auf diesen Stand bringt. Kein Handler fasst das DOM direkt an.

Der Gewinn zeigt sich, sobald etwas dazukommt. Wenn die Anzeige um eine Rundenzeit erweitert wird, ändert sich genau eine Funktion. Wenn stattdessen jeder Handler selbst zeichnet, ändern sich vier, und der fünfte wird vergessen.

Für die Stoppuhr heißt das: zeichnen() wird vom Intervall aufgerufen und von jedem Knopf. Der Knopf braucht das, weil sonst nach dem Zurücksetzen die alte Zeit stehen bliebe, bis das nächste Intervall feuert. Und beim Zurücksetzen feuert gar keines mehr.

Aufräumen, und zwar an drei Stellen

Zweimal Start, zweimal so schnell
const ohneFeld = document.getElementById("ohne");
const mitFeld = document.getElementById("mit");

let ohne = 0;
let mit = 0;
let kennung = null;

document.getElementById("start").addEventListener("click", () => {
  // Ohne Schutz: jeder Klick startet ein weiteres Intervall.
  setInterval(() => {
    ohne = ohne + 1;
    ohneFeld.textContent = String(ohne);
  }, 200);

  // Mit Schutz: läuft schon eines, passiert nichts.
  if (kennung === null) {
    kennung = setInterval(() => {
      mit = mit + 1;
      mitFeld.textContent = String(mit);
    }, 200);
  }
});

Die dritte Stelle, an der es klemmt, ist das Intervall selbst.

Beim Start: Läuft schon eines, darf kein zweites dazukommen. Im dritten Beispiel siehst du, was sonst passiert: Nach dem zweiten Klick auf Start zählt die ungeschützte Uhr doppelt so schnell, denn zwei Intervalle erhöhen dieselbe Zahl. Die Kennung des ersten hast du dabei längst verloren, es lässt sich also auch nicht mehr stoppen.

Der Reiter Debug macht daraus ein Muster, das man nicht mehr vergisst. Er zeichnet auch dann weiter auf, wenn du zwischendurch woanders klickst, und das brauchst du hier: Drück auf Aufzeichnen, wechsel zurück auf Browser (solange Debug vorn liegt, kommst du an die Seite nicht heran), drück zweimal auf Start und geh wieder auf Debug.

Im Protokoll stehen dann auf zwei Schritte der ungeschützten Uhr immer einer der geschützten, immer im selben Takt. Und die Zahlen daneben laufen auseinander: Wo ohne=25 steht, steht mit=16. Zwei Intervalle gegen eines, nachgezählt statt behauptet.

Bei der Pause: clearInterval mit der Kennung, und die Kennung danach auf null. Das null ist nicht Kosmetik, sondern die Information, ob gerade etwas läuft.

Beim Zurücksetzen: dasselbe, und zwar auch dann, wenn die Uhr pausiert ist. Ein clearInterval auf eine Kennung, die es nicht mehr gibt, ist harmlos.

Die Frage, ob gerade ein Intervall läuft, beantwortest du also über die Kennung selbst: kennung === null heißt, es läuft keines. Damit brauchst du keine zweite Variable, die dasselbe noch einmal sagt und irgendwann etwas anderes behauptet.

Die Anzeige formatieren

Zum Schluss eine Kleinigkeit, die überraschend oft falsch aussieht: Aus 65432 Millisekunden soll 01:05:43 werden.

Der Weg dahin führt über Ganzzahldivision und Rest, also Math.floor und % aus Lektion 2.3. Hundertstel sind die Millisekunden durch zehn, Sekunden die Hundertstel durch hundert, Minuten die Sekunden durch sechzig. Und jede Zahl wird mit padStart(2, "0") auf zwei Stellen gebracht, sonst springt die Anzeige zwischen 1:5:3 und 11:15:43 hin und her.

In der Aufgabe ist diese Funktion vorgegeben. Sie ist nicht der Punkt dieser Lektion, aber sie wäre eine gute Übung für zwischendurch.

Zum Mitnehmen

Eine Stoppuhr zählt nicht Durchläufe, sie rechnet Differenzen. Der Timer sagt nur, wann neu gezeichnet wird.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe im Editor, direkt im Browser geprüft

    Öffnet sich mit dem Basis Konto.