post

Frontend-Schnittstellen sind APIs

Warum Props, URLs, Events, CSS-Klassen und localStorage dieselbe Versionierungs-Disziplin verdienen wie ein REST-Endpunkt – spätestens, wenn mehrere Teams im selben Browser arbeiten.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen
 UserCard.propTypes = {
-  name: PropTypes.string.isRequired,
+  fullName: PropTypes.string.isRequired,
   avatarUrl: PropTypes.string,
   onSelect: PropTypes.func
 };

Ein Diff wie dieser geht im Review anstandslos durch. Eine Umbenennung, der neue Name ist sprechender, die eigene Anwendung läuft, die Tests sind grün, gemergt. Drei Wochen später zieht ein anderes Team die neue Version der gemeinsamen Komponenten-Bibliothek, und in deren Browser-Konsole steht:

Warning: Failed prop type: The prop `fullName` is marked as required
  in `UserCard`, but its value is `undefined`.

Das ist noch der gute Fall, weil propTypes wenigstens laut wird. Der schlechtere Fall: Die Karte rendert einfach ohne Namen, niemand schaut in die Konsole, und der Fehler fällt erst auf, wenn ein Anwender ihn meldet – zwei Releases später, mit einem Diff, in dem nichts Verdächtiges mehr steht.

Hätte jemand dasselbe mit einem Feld in einer REST-Antwort gemacht, also name kommentarlos in fullName umbenannt, ohne Ankündigung und ohne Versionssprung, wäre der Ärger groß und berechtigt. Bei einer Komponenten-Prop passiert genau das ständig, und kaum jemand nennt es beim Namen. Dabei ist die Lage identisch: Es gibt einen Vertrag zwischen zwei Codebasen, die von unterschiedlichen Teams zu unterschiedlichen Zeitpunkten geändert und ausgeliefert werden. Das ist die Definition einer API. Dass der Vertrag aus JSX besteht statt aus HTTP, ändert daran nichts.

Mehr Oberflächen, als im Architekturdiagramm stehen

In einem Portal-Projekt, an dem ich länger mitgearbeitet habe, liefen Anwendungsteile von vier Teams im selben Browser-Tab: eine Shell, zwei Fachanwendungen, ein eingebettetes Widget. Als wir nach einem besonders zähen Integrationsfehler einmal aufgeschrieben haben, worüber diese Teile tatsächlich miteinander reden, kam eine Liste heraus, die deutlich länger war als alles, was je in einem Architekturdiagramm stand:

  • Props und Callbacks der geteilten Komponenten-Bibliothek
  • URLs mit Pfaden und Query-Parametern, auf die verlinkt wird
  • CustomEvents und postMessage-Nachrichten zwischen Seitenteilen
  • CSS-Klassen und CSS Custom Properties, die andere Teile gezielt verwenden
  • localStorage-Schlüssel, die mehr als ein Modul liest

Jede dieser Oberflächen hatte Konsumenten außerhalb des Teams, das sie pflegte. Keine davon war versioniert. Nur die erste war überhaupt dokumentiert. Die REST-APIs desselben Projekts hatten dagegen Versionspräfixe, Changelogs und eine Deprecation-Richtlinie – weil dort allen klar war, dass fremder Code von ihnen abhängt. Im Frontend war dieselbe Abhängigkeit da, nur unsichtbar.

flowchart LR
  subgraph FE[Frontend eines Teams]
    P[Props & Callbacks]
    U[URLs & Parameter]
    E[CustomEvents /<br/>postMessage]
    C[CSS-Klassen &<br/>Custom Properties]
    S[localStorage-<br/>Schlüssel]
  end
  A[Team B:<br/>Fachanwendung] --> P
  B[Nutzer: Bookmarks,<br/>geteilte Links] --> U
  M[Mails, Kampagnen,<br/>Monitoring-Checks] --> U
  W[Widget im iframe] --> E
  T[Team C:<br/>Theming] --> C
  X[Altes Portal-Skript] --> S

Das Diagramm ist keine Übertreibung, sondern die aufgeräumte Version unserer damaligen Liste. Und es erklärt, warum „wir refactoren nur intern" im Frontend so oft schiefgeht: Was intern aussieht, hat längst Konsumenten.

Props: der Vertrag zwischen Teams

Für die Komponenten-Bibliothek haben wir daraus die naheliegende Konsequenz gezogen: Sie wird wie eine API behandelt. Konkret heißt das dreierlei. Erstens ist die Prop-Signatur der Vertrag, und propTypes machen ihn maschinenlesbar. Zweitens gilt Semantic Versioning ernsthaft – eine entfernte oder umbenannte Prop ist ein Breaking Change und damit eine Major-Version, egal wie klein der Diff aussieht. Drittens bekommt jede Änderung ein Deprecation-Fenster: Die alte Form funktioniert weiter, warnt aber im Entwicklungsmodus.

Für das UserCard-Beispiel von oben sieht das so aus:

import React from 'react';
import PropTypes from 'prop-types';

// v2.3.0: `name` is deprecated in favor of `fullName`.
// Both work until v3.0.0, where `name` will be removed.
export function UserCard({ fullName, name, avatarUrl, onSelect }) {
  const displayName = fullName || name;

  if (name && process.env.NODE_ENV !== 'production') {
    console.warn(
      'UserCard: prop `name` is deprecated, use `fullName` instead. ' +
      'Support for `name` will be removed in v3.0.0.'
    );
  }

  return (
    <div className="user-card" onClick={onSelect}>
      {avatarUrl && <img className="user-card__avatar" src={avatarUrl} alt="" />}
      <span className="user-card__name">{displayName}</span>
    </div>
  );
}

UserCard.propTypes = {
  fullName: PropTypes.string,
  name: PropTypes.string, // deprecated, see above
  avatarUrl: PropTypes.string,
  onSelect: PropTypes.func
};

Das ist mehr Code als die reine Umbenennung, und genau dieser Mehraufwand ist der Punkt. Er zwingt zu der Frage, ob die Umbenennung den Preis wert ist. Bei einer internen Hilfsfunktion ist die Antwort fast immer ja, weil der Compiler oder die Suche alle Aufrufer findet. Bei einer veröffentlichten Prop findet man die Aufrufer nicht – sie sitzen in Repositories, auf die man keinen Zugriff hat.

Als Dokumentation hat sich bei uns Storybook bewährt – als lebende Vertragsbeschreibung, nicht bloß als hübscher Komponenten-Katalog: Für jede Komponente gibt es Stories, die die unterstützten Prop-Kombinationen zeigen, inklusive einer Story für die deprecatete Form, solange sie existiert. Wer wissen will, was UserCard kann, schaut nicht in den Quellcode des Komponenten-Teams, sondern in die gebaute Storybook-Instanz der Version, die er einsetzt. Das Team, das die Bibliothek pflegt, merkt dadurch außerdem selbst früher, wenn eine Änderung den Vertrag berührt – eine kaputte Story ist ein deutliches Signal.

URLs: der Vertrag mit allen anderen

Die zweite Oberfläche wird noch häufiger unterschätzt: die URL. Routen fühlen sich wie ein internes Implementierungsdetail des Routers an, sind aber das genaue Gegenteil – die öffentlichste Schnittstelle, die ein Frontend hat. Auf eine Route zeigen Bookmarks, per Mail geteilte Links, Kampagnen, Monitoring-Checks, die Dokumentation des Nachbarteams und gelegentlich ein PDF, das vor zwei Jahren gedruckt wurde. Keiner dieser Konsumenten bekommt ein Update, wenn jemand im Code /clients in /customers umbenennt.

Eine Route umzubenennen ist deshalb ein Breaking Change mit besonders langlebigen Konsumenten, und die Deprecation-Phase heißt hier Redirect. Mit React Router v4 ist das eine Zeile plus Disziplin:

import { Switch, Route, Redirect } from 'react-router-dom';

<Switch>
  <Route path="/customers/:customerId" component={CustomerPage} />
  {/* Deprecated 2018-08: keep this redirect for at least 12 months. */}
  <Redirect from="/clients/:customerId" to="/customers/:customerId" />
</Switch>

Die Disziplin steckt im Kommentar: Der Redirect bleibt, bis die alten Links realistisch aus der Welt sind, nicht bis zum nächsten Aufräum-Commit. In dem Portal-Projekt haben wir irgendwann Zugriffe auf alte Routen mitgezählt, bevor ein Redirect entfernt wurde – dieselbe Praxis, mit der man auch einen alten REST-Endpunkt abschaltet. Die Zahlen waren regelmäßig ernüchternd: Links, die wir für tot hielten, wurden noch Monate später aufgerufen.

Dasselbe gilt für Query-Parameter. Wer ?tab=invoices unterstützt, hat einen Vertrag geschlossen, sobald der erste Nutzer den Link teilt. Und die URL ist gleichzeitig persistierter Zustand: Was in ihr steht, übersteht Reloads und wandert zwischen Browsern – ein Grund mehr, sie bewusst zu entwerfen statt sie dem Router-Zufall zu überlassen. Bewusst entwerfen heißt vor allem: fachliche Bezeichner statt interner IDs, keine Implementierungsdetails im Pfad, und ein Schema, das ein Kollege ohne Blick in den Code erraten kann. Eine URL, die man am Telefon vorlesen kann, überlebt erfahrungsgemäß auch das nächste Redesign.

Events und Messages: Verträge zur Laufzeit

Sobald mehrere Anwendungsteile im selben Browser laufen, reden sie irgendwann direkt miteinander – bei uns über CustomEvents auf window und über postMessage zum eingebetteten Widget. Diese Nachrichten sind die flüchtigste Schnittstellenform: kein Typ-Check, kein gemeinsames Repository, oft nicht einmal ein gemeinsames Deployment. Genau deshalb brauchen sie den explizitesten Vertrag.

Wir haben dafür jede Nachricht wie ein kleines Wire-Format behandelt – benannter Typ, dokumentiertes Payload-Schema, Versionsfeld:

// Contract: 'cart:item-added' (version 1)
// detail: { version: number, sku: string, quantity: number, source: string }
window.dispatchEvent(new CustomEvent('cart:item-added', {
  detail: { version: 1, sku: 'A-4711', quantity: 1, source: 'product-page' }
}));

// Consumer side: tolerate unknown versions instead of guessing.
window.addEventListener('cart:item-added', (event) => {
  if (event.detail.version !== 1) return;
  updateCartBadge(event.detail.quantity);
});

Das Versionsfeld wirkt übervorsichtig, bis zum ersten Mal zwei Seitenteile mit unterschiedlichem Release-Stand im selben Tab laufen. Dann ist es der Unterschied zwischen einem ignorierten Event und einem undefined, das drei Funktionen weiter explodiert. Bei postMessage kommt die Origin-Prüfung dazu, aber das Prinzip bleibt: Der Empfänger validiert, der Sender verspricht Abwärtskompatibilität innerhalb einer Version. Custom Elements v1 macht diese Denkweise übrigens offiziell – ein Element mit Attributen und Events ist nichts anderes als eine im Browser registrierte API.

CSS und localStorage: die Verträge, die niemand schließen wollte

Die letzten beiden Oberflächen sind die unangenehmsten, weil sie Verträge sind, die nie jemand bewusst geschlossen hat. Wer BEM-Klassen wie user-card__avatar ins DOM schreibt, publiziert sie. Irgendwo stylt ein anderes Team gegen diese Klasse, ein Test selektiert sie, ein Analytics-Snippet hängt daran. Nach derselben Logik sind CSS Custom Properties, die ein Theme nach außen anbietet – --brand-primary, --spacing-unit –, eine echte, gewollte API und sollten auch so gepflegt werden: benannt, dokumentiert, stabil. Die ungewollte Variante lässt sich eindämmen, indem man klar trennt, welche Klassen Vertragsbestandteil sind und welche generiert oder als privat markiert sind. Aber die Grundregel bleibt: Was beobachtbar ist und lange genug stabil bleibt, wird benutzt werden – unabhängig davon, was die Dokumentation dazu sagt.

localStorage ist derselbe Fall in schlimmer: global pro Origin, ohne Namensräume, ohne Schema. Ein Schlüssel wie user-settings, den zwei Module lesen, ist eine ungewollte Datenbank-API inklusive Migrationsproblem – ändert ein Modul das Format, parst das andere Müll. Dazu kommt eine Eigenschaft, die Storage von allen anderen Oberflächen unterscheidet: Die alten Werte sind schon draußen. Ein Deployment tauscht den Code aus, aber nicht die Daten in tausenden Browsern – jede Formatänderung trifft dort auf Werte, die eine ältere Version geschrieben hat. Seit dem Portal-Projekt bekommen solche Schlüssel bei mir deshalb ein Präfix mit Modulname und eine Versionsnummer im Wert, und der lesende Code behandelt ein unbekanntes Format wie einen fehlenden Wert. Nicht weil das elegant wäre, sondern weil die Alternative ein Produktionsfehler ist, den man nur mit „Storage im DevTools-Panel leeren" diagnostizieren kann.

Versionierung ist Kommunikation, nicht Bürokratie

Der Einwand liegt nahe: Das ist viel Prozess für ein Frontend. Aber der Aufwand ist gestaffelt, und der größte Teil ist schlicht Kommunikation. Für die Komponenten-Bibliothek in unserem Lerna-Monorepo sah das Fenster für einen Breaking Change so aus:

  • Änderung im Changelog ankündigen, mit altem und neuem Vertrag
  • eine Minor-Version, in der beides funktioniert und die alte Form im Entwicklungsmodus warnt
  • Storybook-Story für die alte Form als deprecated markieren
  • Entfernung erst in der nächsten Major-Version, mit Migrationshinweis

Das kostet pro Änderung vielleicht eine Stunde. Der Prop-Rename ohne dieses Fenster hat uns damals, alle Beteiligten zusammengerechnet, mehrere Tage gekostet – Fehlersuche in einer fremden Codebasis, Hotfix, Sonderrelease, und das Vertrauen zwischen zwei Teams gleich mit. Über die schludrige Behandlung von Code, der zwischen den Zuständigkeiten liegt, habe ich unter anderem in Build-Skripte als Produktcode schon einmal geschrieben; Frontend-Schnittstellen sind derselbe blinde Fleck, nur mit mehr Konsumenten.

Mir ist dabei aufgefallen, dass die Teams gar nicht an der Disziplin scheitern, sondern an der Wahrnehmung. Niemand weigert sich, eine API sauber zu versionieren – man sieht nur nicht, dass man gerade eine hat. Der wirksamste Schritt war deshalb nicht das Tooling, sondern die Liste vom Anfang: einmal aufschreiben, welche Oberflächen das eigene Frontend tatsächlich anbietet und wer daran hängt. Alles, was auf dieser Liste steht und einen Konsumenten außerhalb des eigenen Teams hat, verdient die drei bekannten Zutaten – expliziter Vertrag, semantische Version, Deprecation-Fenster.

Unterm Strich

Ein Frontend ist kein Blatt am Ende des Abhängigkeitsbaums, sondern selbst ein Anbieter von Schnittstellen: Props, URLs, Events, CSS-Oberflächen, gespeicherte Schlüssel. Solange ein einziges Team alles davon besitzt und in einem Schritt ausliefert, darf man das ignorieren. Spätestens wenn mehrere Teams im selben Browser arbeiten – und die Design-System- und Micro-Frontend-Bewegung sorgt gerade dafür, dass das der Normalfall wird –, gelten die Regeln, die im Backend seit Jahren selbstverständlich sind. Breaking Change heißt Major-Version. Umbenennen heißt deprecaten. Und ein harmlos aussehender Diff ist genau so harmlos wie die Zahl der Konsumenten, die niemand gefragt hat.

Weiterführende Quellen

Kommentare