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.
In meinen Schulungen kommt an einer Stelle fast immer dieselbe Frage: „Warum aktualisiert sich die Oberfläche eigentlich von selbst, wenn ich nur eine Variable ändere?" Die Antwort hängt stark davon ab, welches Framework auf dem Tisch liegt. Bei KnockoutJS lässt sich diese Frage besonders sauber beantworten, weil die Reaktivität dort nicht hinter viel Zeremonie versteckt ist. Man sieht dem Modell direkt an, wie es funktioniert – und genau deshalb eignet es sich so gut, um das Prinzip einmal von Grund auf zu verstehen.
Ich habe in einem früheren Beitrag bereits gezeigt, wie KnockoutJS und Bindings das DOM deklarativ an ein ViewModel koppeln. Dort ging es um die Binding-Palette: text, value, foreach, click und Kollegen. Heute schaue ich eine Ebene tiefer. Es geht nicht mehr darum, welche Bindings es gibt, sondern um die Mechanik dahinter: Wie merkt Knockout überhaupt, dass sich etwas geändert hat, und wie findet es heraus, welche Stellen der Oberfläche davon betroffen sind? Das ist der Kern, und der heißt bei Knockout: Observables und Computed.
Ein Observable ist eine Funktion – keine Eigenschaft
Der erste gedankliche Sprung, den viele Teilnehmer machen müssen, 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 in der Schulung gerne langsam durchgehe, 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 also nicht durch Deklaration, sondern zur Laufzeit, durch reines Ausführen. 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.
Diesen Zusammenhang skizziere ich meist so an der Tafel:
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 mitobs(), geschrieben mitobs(newValue).ko.observableArray([...])– eine reaktive Liste mit Mutations-Methoden wiepush,removeundsplice, 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 das Beispiel, das ich in der Schulung tatsächlich durchspiele: 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 scheitern die meisten Anfänger, und fast immer an denselben drei Stellen. Ich zeige sie hier bewusst, weil sie in der eigenen Anwendung viel Zeit kosten können.
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. Meine Merkregel für die Schulung: Zum Lesen gehören immer Klammern.
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 Schulungen 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 steckt nicht in ihrer Zahl, sondern im automatischen Dependency-Tracking dazwischen. 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.
Kommentare