Blog

KnockoutJS: fein-granulare Reaktivität mit Observables und Computed

Wie KnockoutJS Reaktivität löst: Observables als get/set-Funktionen, Computed mit automatischem Dependency-Tracking und gezielte DOM-Updates statt globalem Baum-Vergleich.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen

Drei Funktionen genügen, um in KnockoutJS eine ganze Oberfläche reaktiv zu halten: ko.observable, ko.observableArray und ko.computed. Keine davon nimmt eine Abhängigkeitsliste entgegen. Trotzdem weiß Knockout nach dem ersten Durchlauf, welcher Wert an welcher Stelle im DOM hängt. Es liest die Abhängigkeiten aus dem tatsächlichen Programmablauf ab.

Redaktionelle Einordnung (Juli 2026): Dieser Beitrag erklärt KnockoutJS als historischen Vorläufer heutiger fein-granularer Reaktivität. Ich empfehle damit nicht, 2026 ein neues Frontend auf Knockout aufzubauen. Interessant geblieben ist die Mechanik: Abhängigkeiten beim Lesen erfassen und nur die betroffenen Stellen aktualisieren.

Knockout koppelt das DOM über Bindings wie text, value, foreach und click deklarativ an ein ViewModel. Hier schaue ich eine Ebene tiefer: Wie merkt Knockout, dass sich etwas geändert hat, und wie findet es heraus, welche Stellen der Oberfläche davon betroffen sind? Der Kern dieser Mechanik heißt Observables und Computed.

Ein Observable ist eine Funktion – keine Eigenschaft

Der erste gedankliche Sprung, den man bei Knockout machen muss, ist dieser: Ein Observable ist in KnockoutJS keine Eigenschaft, sondern eine Funktion. Genauer gesagt eine Funktion, die gleichzeitig Getter und Setter ist. Rufe ich sie ohne Argument auf, lese ich den Wert. Rufe ich sie mit einem Argument auf, schreibe ich den Wert – und Knockout benachrichtigt alle, die an diesem Wert interessiert sind.

var name = ko.observable('Bob');

name();          // read  -> 'Bob'
name('Mary');    // write -> sets the value and notifies subscribers
name();          // read  -> 'Mary'

Das ist der ganze Trick. Kein Object.defineProperty, kein Proxy, keine Compiler-Magie. Nur eine Funktion, die sich merkt, wer sie gelesen hat und wer benachrichtigt werden will. Das Schreiben ist übrigens chainbar, weil der Setter das ViewModel zurückgibt:

viewModel.name('Mary').age(50);

Für Listen gibt es die passende Variante. Ein ko.observableArray verhält sich beim Lesen wie ein Observable – arr() liefert das dahinterliegende Array – bringt aber zusätzlich Mutations-Methoden mit, die das Tracking auslösen:

var todos = ko.observableArray([]);

todos();                 // read -> the current array
todos.push({ title: 'Buy milk' });
todos.remove(someItem);
todos.removeAll();

Hier lauert schon der erste Fallstrick, auf den ich später zurückkomme: Diese push-, remove- und splice-Methoden sind Knockout-eigene Wrapper. Sie sind es, die eine Benachrichtigung auslösen. Wer stattdessen todos().push(x) schreibt, greift am rohen Array vorbei und Knockout bekommt von der Änderung nichts mit.

Computed leitet ab – und verfolgt automatisch, was es liest

Jetzt kommt der eigentlich interessante Teil. Selten will man einen Wert nur speichern und wieder ausgeben. Meistens braucht man abgeleitete Werte: die Summe eines Warenkorbs, den vollständigen Namen aus Vor- und Nachname, die Anzahl offener Aufgaben. Dafür gibt es ko.computed.

var firstName = ko.observable('Mike');
var lastName  = ko.observable('Bild');

var fullName = ko.computed(function () {
  return firstName() + ' ' + lastName();
});

fullName();            // 'Mike Bild'
firstName('Michael');
fullName();            // 'Michael Bild'

Und hier passiert etwas, das ich mir beim ersten Kontakt mit Knockout selbst zweimal angeschaut habe, weil es der Dreh- und Angelpunkt des ganzen Modells ist. Ich habe Knockout nirgends gesagt, dass fullName von firstName und lastName abhängt. Ich habe keine Abhängigkeitsliste deklariert. Knockout hat das selbst herausgefunden.

Wie? Beim ersten Ausführen der Evaluator-Funktion beobachtet Knockout, welche Observables tatsächlich gelesen werden. firstName() und lastName() melden sich in diesem Moment gewissermaßen an: „Dieses Computed liest gerade mich.“ Knockout registriert daraufhin eine Subscription. Der Dependency-Graph entsteht damit zur Laufzeit, allein durchs Ausführen – deklariert wird nichts. Das ist das fein-granulare, automatische Dependency-Tracking, das Knockout ausmacht.

Der Evaluator läuft dabei nicht nur einmal. Er wird sofort bei der Erstellung einmal ausgeführt, und danach jedes Mal neu, wenn sich eine seiner gelesenen Abhängigkeiten ändert. Wichtig ist der zweite Teil: Bei jeder Neuauswertung ermittelt Knockout die Abhängigkeiten frisch. Das erlaubt dynamische Abhängigkeiten hinter Bedingungen:

var showFullName = ko.observable(true);

var displayName = ko.computed(function () {
  if (showFullName()) {
    return firstName() + ' ' + lastName();
  }
  return firstName();
});

Solange showFullName auf true steht, hängt displayName von drei Observables ab. Steht es auf false, wird lastName() gar nicht mehr gelesen – und Knockout entfernt diese Abhängigkeit beim nächsten Lauf automatisch wieder. Eine Änderung an lastName löst dann keine Neuauswertung mehr aus. Genau das meint „dynamisch“: Der Graph passt sich dem tatsächlichen Kontrollfluss an.

Als Graph gezeichnet sieht dieser Zusammenhang so aus:

graph LR
  A[firstName<br/>observable] --> C[fullName<br/>computed]
  B[lastName<br/>observable] --> C
  C --> D[text-binding<br/>DOM]

Die Pfeile bedeuten „wird gelesen von und benachrichtigt“. Ändert sich firstName, wandert die Benachrichtigung entlang der Kante zu fullName, das sich neu berechnet, und von dort weiter zum Text-Binding im DOM. Nur dieser eine Pfad wird aktiv. Nichts anderes im Baum wird angefasst.

Für den Werkzeugkasten reicht fürs Erste dieses kleine Trio:

  • ko.observable(value) – ein einzelner reaktiver Wert, gelesen mit obs(), geschrieben mit obs(newValue).
  • ko.observableArray([...]) – eine reaktive Liste mit Mutations-Methoden wie push, remove und splice, die Bindings benachrichtigen.
  • ko.computed(fn) – ein abgeleiteter Wert, der automatisch verfolgt, welche Observables er liest, und sich bei deren Änderung neu berechnet.

Vom ViewModel ins DOM: die Todos-App

Damit das nicht abstrakt bleibt, hier eine kleine Todos-Anwendung. Sie zeigt alle drei Bausteine im Zusammenspiel.

function TodoViewModel() {
  var self = this;

  self.todos = ko.observableArray([]);
  self.newTodo = ko.observable('');

  self.remaining = ko.computed(function () {
    return self.todos().filter(function (t) {
      return !t.done();
    }).length;
  });

  self.addTodo = function () {
    self.todos.push({
      title: self.newTodo(),
      done: ko.observable(false)
    });
    self.newTodo('');
  };
}

ko.applyBindings(new TodoViewModel());

Ein paar Dinge sind hier bewusst so gebaut. self.todos ist ein observableArray, self.newTodo ein einfaches Observable für das Eingabefeld. self.remaining ist ein Computed, das die noch offenen Aufgaben zählt. Es liest self.todos() und pro Eintrag t.done() – und damit hängt es sowohl an der Liste als auch an jedem einzelnen done-Observable. Fügt man ein Todo hinzu oder hakt eines ab, rechnet remaining von selbst neu.

Beachtenswert ist, dass jedes einzelne Todo ein eigenes done: ko.observable(false) bekommt. Nur so ist der Erledigt-Status pro Eintrag reaktiv. Ein reines done: false wäre ein toter Wert, an dem sich nichts beobachten ließe.

Die View koppelt das ViewModel deklarativ ans DOM. Hier der relevante Ausschnitt:

<input data-bind="value: newTodo" />
<button data-bind="click: addTodo">Add</button>

<ul data-bind="foreach: todos">
  <li data-bind="text: title"></li>
</ul>

<span data-bind="text: remaining"></span> remaining

ko.applyBindings aktiviert diese Verknüpfungen einmalig beim Start. Ab da läuft alles über das Tracking: Tippt der Nutzer ins Feld, aktualisiert das value-Binding newTodo. Ein Klick auf den Button ruft addTodo auf, das per push die Liste verändert. Diese Änderung benachrichtigt das foreach-Binding (neuer Listeneintrag) und das Computed remaining (Neuberechnung), das wiederum das text-Binding im <span> aktualisiert. Jede dieser Aktualisierungen trifft gezielt genau die Stelle, die betroffen ist.

Warum gezieltes Tracking ein anderes Modell ist

An diesem Punkt lohnt sich die Abgrenzung, die dem Ganzen erst Kontur gibt. Es gibt einen zweiten, sehr verbreiteten Ansatz für dasselbe Problem: das Virtual-DOM-Diffing, wie man es etwa von React kennt. Dort baut man bei jeder Änderung einen neuen virtuellen Baum, vergleicht ihn mit dem alten und wendet die Differenz aufs echte DOM an. Das Framework weiß nicht von vornherein, welche Datenänderung welche DOM-Stelle betrifft – es rechnet den Unterschied nachträglich aus.

Knockout geht den umgekehrten Weg. Durch das fein-granulare Tracking weiß es pro Observable exakt, welche Computed und welche Bindings davon abhängen. Es muss nichts vergleichen. Ändert sich ein Wert, folgt Knockout einfach den registrierten Kanten des Dependency-Graphen und aktualisiert nur die dort hängenden DOM-Stellen. Kein Baum-Vergleich, kein Neu-Rendern von Teilbäumen, die sich gar nicht geändert haben.

Die Sequenz einer einzelnen Änderung sieht so aus:

sequenceDiagram
  participant U as User
  participant O as observable
  participant C as computed
  participant D as DOM-Binding
  U->>O: obs(newValue)
  O->>C: notify subscriber
  C->>C: re-evaluate
  C->>D: gezieltes text-Update

Beide Modelle haben ihre Berechtigung, und ich möchte keines zum Sieger erklären. Aber wer den Unterschied einmal an dieser Stelle verstanden hat, versteht auch, warum die beiden Welten sich so unterschiedlich anfühlen. Knockouts Ansatz ist unmittelbar nachvollziehbar, weil man den Graphen im Code fast sehen kann. Der Preis dafür ist die Disziplin: Man muss konsequent mit Observables arbeiten, sonst reißt der Graph an einer Stelle ab.

Fallstricke aus der Praxis

Genau an dieser Disziplin reißt der Graph gern, und zwar an drei wiederkehrenden Stellen. Alle drei haben mich in eigenen Projekten schon Stunden gekostet, weil sie stillschweigend danebengehen.

Der Klassiker sind vergessene Klammern. Ein Observable ist eine Funktion. Wer sie ohne Klammern verwendet, arbeitet mit der Funktion selbst statt mit ihrem Wert:

// wrong: todo.done is the function itself, so always truthy
if (todo.done) {
  // this branch ALWAYS runs
}

// correct: todo.done() reads the value
if (todo.done()) {
  // ...
}

Der Fehler ist tückisch, weil er keinen Fehler wirft. Eine Funktion ist truthy, also läuft die Bedingung schlicht immer ins gleiche Ergebnis. Man sucht dann lange an der falschen Stelle. Zum Lesen gehören immer Klammern, ohne Ausnahme.

Der zweite Fallstrick sind die nativen Array-Methoden. Ich hatte es oben angedeutet:

// wrong: mutates the raw array without notification
todos().push(newItem);

// correct: KO wrapper, notifies dependent bindings
todos.push(newItem);

todos() liefert das darunterliegende JavaScript-Array. Dessen natives push verändert zwar den Inhalt, aber Knockout erfährt nichts davon – das foreach-Binding bleibt stehen. Der KO-Wrapper todos.push hingegen löst das Tracking aus. Der Unterschied liegt einzig in den Klammern vor dem .push, was den Fehler wieder unangenehm subtil macht.

Der dritte betrifft den this-Kontext in Computed. Wenn der Evaluator auf das ViewModel zugreifen will, muss this auch wirklich aufs ViewModel zeigen:

function ViewModel() {
  this.price = ko.observable(10);
  this.count = ko.observable(3);

  // wrong: this does not reliably point to the ViewModel here
  this.total = ko.computed(function () {
    return this.price() * this.count();   // this.price is undefined
  });
}

Der Evaluator wird von Knockout in einem anderen Kontext aufgerufen, sodass this nicht auf die Instanz zeigt. Es gibt zwei etablierte Auswege. Entweder man reicht den Kontext als zweites Argument nach:

this.total = ko.computed(function () {
  return this.price() * this.count();
}, this);   // second argument binds this

Oder man verwendet das self-Muster, das ich im Todos-Beispiel schon genutzt habe:

function ViewModel() {
  var self = this;
  self.price = ko.observable(10);
  self.count = ko.observable(3);
  self.total = ko.computed(function () {
    return self.price() * self.count();
  });
}

Ich bevorzuge in meinen Projekten das self-Muster, weil es durchgängig funktioniert – auch in Event-Handlern und Callbacks, nicht nur in Computed. Ein Muster für alles ist leichter zu merken als eine Sonderregel pro Fall.

Am Rande sei noch ko.pureComputed erwähnt, das seit KO 3.2 zur Verfügung steht. Es verhält sich wie ein normales Computed, „schläft“ aber, solange niemand es abonniert, und spart so Neuberechnungen bei reinen Ableitungen ohne Seiteneffekte. Für den Einstieg reicht ko.computed; sobald man merkt, dass ein rein ableitendes Computed unnötig oft rechnet, ist pureComputed die naheliegende Verbesserung.

Fazit

Wer den Kern von KnockoutJS verstanden hat, hält im Grunde nur drei Bausteine in der Hand: Observables als get/set-Funktionen, observableArrays für reaktive Listen und Computed für abgeleitete Werte. Das eigentlich Elegante liegt zwischen diesen Bausteinen: im automatischen Dependency-Tracking. Knockout beobachtet zur Laufzeit, welche Observables ein Computed liest, baut daraus einen Dependency-Graphen und aktualisiert bei jeder Änderung gezielt nur die betroffenen Stellen – neu ermittelt bei jedem Lauf, damit auch dynamische Abhängigkeiten hinter Bedingungen sauber abgebildet werden.

Dieses Modell steht bewusst quer zum Virtual-DOM-Diffing: Statt Bäume zu vergleichen, folgt Knockout den Kanten, die es beim Lesen selbst registriert hat. Das macht das Verhalten unmittelbar nachvollziehbar, verlangt aber Disziplin – Klammern zum Lesen, KO-Wrapper zum Mutieren von Listen, den richtigen this-Kontext im Computed. Sind diese drei Reflexe verinnerlicht, wird das Arbeiten mit Knockout überraschend ruhig. Man beschreibt, wovon ein Wert abhängt, und das Framework kümmert sich um den Rest. Genau diese Klarheit ist der Grund, warum ich Knockouts Reaktivitätsmodell nach wie vor für einen der besten Einstiege halte, um zu begreifen, was hinter reaktiven Oberflächen wirklich passiert.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare