Skip to content

Formulare mit JSON Patch ​

Bei der Implementierung großer Formulare oder kollaborativer Echtzeitbearbeitung führt der traditionelle Ansatz „das gesamte Formular absenden" zu Performance- und Benutzererfahrungsproblemen.

Dieser Artikel erklärt fortgeschrittene Formularmuster unter Verwendung von JSON Patch (RFC 6902). Durch das Senden nur der Unterschiede können Sie die Netzwerkbandbreite reduzieren und Undo/Redo sowie kollaborative Bearbeitung effizient implementieren.

Was Sie in diesem Artikel lernen werden ​

  • Grundlagen von JSON Patch/Pointer (RFC 6902/6901)
  • Automatisches Speichern großer Formulare (differenzbasiert)
  • Implementierung von Undo/Redo (inverse Patches)
  • Echtzeit-Synchronisation für kollaborative Bearbeitung
  • Grundlagen von Operational Transform (OT) / CRDT
  • Integrationsmuster von WebSocket und RxJS
  • Konfliktlösung und Versionsverwaltung

Voraussetzungen

Dieser Artikel setzt Kenntnisse von Kapitel 4: Operatoren, Formularverarbeitungsmuster und Echtzeitdatenverarbeitung voraus.

Wann diese Muster erforderlich sind

  • Große Formulare (100+ Felder) mit automatischem Speichern
  • Undo/Redo-Funktionalität ist erforderlich
  • Kollaborative Echtzeitbearbeitung (wie Google Docs)
  • Offline-Unterstützung mit Diff-Queueing erforderlich

Für kleine Formulare (~20 Felder) sind normale Formularverarbeitungsmuster ausreichend.

Grundlagen von JSON Patch/Pointer ​

Was ist JSON Patch ​

JSON Patch (RFC 6902) ist ein Standardformat zur Darstellung von Änderungen an JSON-Dokumenten. Sie können nur die Änderungen senden, statt das gesamte Formular.

typescript
// Formulardaten vor der Änderung
const before = {
  profile: {
    name: "Hans Müller",
    email: "tanaka@example.com",
    age: 30
  }
};

// Formulardaten nach der Änderung
const after = {
  profile: {
    name: "Hans Müller",
    email: "tanaka.updated@example.com", // geändert
    age: 31 // geändert
  }
};

// JSON Patch (Differenz)
const patch = [
  { op: "replace", path: "/profile/email", value: "tanaka.updated@example.com" },
  { op: "replace", path: "/profile/age", value: 31 }
];

Die 6 JSON Patch-Operationen

  • add - Wert hinzufügen
  • remove - Wert entfernen
  • replace - Wert ersetzen
  • move - Wert verschieben
  • copy - Wert kopieren
  • test - Wert testen (Validierung)

Was ist JSON Pointer ​

JSON Pointer (RFC 6901) ist eine Pfadnotation zur Referenzierung spezifischer Werte in JSON-Dokumenten.

typescript
const formData = {
  user: {
    profile: {
      name: "Hans Müller"
    },
    settings: {
      notifications: true
    }
  }
};

// JSON Pointer Beispiele
"/user/profile/name"           // → "Hans Müller"
"/user/settings/notifications" // → true
"/user/profile"                // → { name: "Hans Müller" }

Differenzerkennung mit RxJS ​

Kombinieren Sie pairwise() mit der fast-json-patch-Bibliothek, um Formularänderungen automatisch zu erkennen.

typescript
import { BehaviorSubject, pairwise, map } from 'rxjs';
import { compare } from 'fast-json-patch';

interface FormData {
  profile: {
    name: string;
    email: string;
    age: number;
  };
}

const initialData: FormData = {
  profile: {
    name: "",
    email: "",
    age: 0
  }
};

const formData$ = new BehaviorSubject<FormData>(initialData);

// Unterschiede erkennen
const patches$ = formData$.pipe(
  pairwise(), // Paare aus [vorheriger Wert, aktueller Wert] abrufen
  map(([previous, current]) => compare(previous, current))
);

patches$.subscribe(patches => {
  console.log('Erkannte Änderungen:', patches);
  // Beispiel: [{ op: "replace", path: "/profile/name", value: "Hans Müller" }]
});

// Formularaktualisierung simulieren
formData$.next({
  profile: {
    name: "Hans Müller",
    email: "tanaka@example.com",
    age: 30
  }
});

fast-json-patch Bibliothek

bash
npm install fast-json-patch
  • Vollständig RFC 6902-konform
  • Diff-Generierung (compare) und Anwendung (applyPatch)
  • Inverse Patch-Generierung (für Undo)
  • TypeScript-Unterstützung

Automatisches Speichern und Undo/Redo für große Formulare ​

Implementieren Sie automatisches Speichern und Undo/Redo-Funktionalität für große Formulare (z.B. 100-Felder-Mitgliederregistrierung, Produktverwaltungsbildschirme).

Implementierungsstrategie ​

Frontend-Verantwortlichkeiten:

  • Generierung und Sortierung von Operationen (Änderungen)
  • Optimistische UI-Reflexion (sofortige Anwendung mit scan)
  • Undo/Redo-Stack-Verwaltung (Historie mit inversen Patches)
  • Sendewarteschlangenverwaltung (Reihenfolgegarantie mit concatMap)
  • Batching (bufferTime + Komprimierung)

Backend-Verantwortlichkeiten:

  • Versionsverwaltung (Vector Clock / Zeitstempel)
  • Idempotenzgarantie (Duplikaterkennung mit Request-ID)
  • Persistierung und Audit-Log

Muster 1: Grundlegendes automatisches Speichern ​

Erkennen Sie Formularänderungen und senden Sie sie in regelmäßigen Abständen in Batches an den Server.

typescript
import {
  BehaviorSubject,
  Subject,
  pairwise,
  map,
  bufferTime,
  filter,
  concatMap,
  catchError,
  of
} from 'rxjs';
import { compare, Operation } from 'fast-json-patch';

interface LargeFormData {
  personalInfo: {
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
    dateOfBirth: string;
  };
  address: {
    street: string;
    city: string;
    state: string;
    zipCode: string;
    country: string;
  };
  preferences: {
    newsletter: boolean;
    notifications: boolean;
    theme: 'light' | 'dark';
  };
  // ... 100+ Felder angenommen
}

const initialFormData: LargeFormData = {
  personalInfo: {
    firstName: "",
    lastName: "",
    email: "",
    phone: "",
    dateOfBirth: ""
  },
  address: {
    street: "",
    city: "",
    state: "",
    zipCode: "",
    country: "Japan"
  },
  preferences: {
    newsletter: false,
    notifications: true,
    theme: 'light'
  }
};

// Stream der Formulardaten
const formData$ = new BehaviorSubject<LargeFormData>(initialFormData);

// Stream der Speicherergebnisse
const saveResult$ = new Subject<{ success: boolean; message: string }>();

// Demo-Statusanzeigeelement
const statusDiv = document.createElement('div');
statusDiv.style.padding = '10px';
statusDiv.style.margin = '10px';
statusDiv.style.border = '2px solid #ccc';
statusDiv.style.borderRadius = '4px';
statusDiv.style.fontFamily = 'monospace';
statusDiv.style.fontSize = '14px';
document.body.appendChild(statusDiv);

function updateStatus(message: string, color: string = '#333') {
  statusDiv.innerHTML = `<span style="color: ${color}">${message}</span>`;
}

// Automatische Speicher-Pipeline
formData$.pipe(
  pairwise(),
  map(([previous, current]) => ({
    patches: compare(previous, current),
    timestamp: Date.now()
  })),
  filter(({ patches }) => patches.length > 0), // Überspringen, wenn keine Änderungen
  bufferTime(2000), // Änderungen über 2 Sekunden puffern
  filter(buffer => buffer.length > 0), // Leeren Puffer überspringen
  map(buffer => {
    // Alle Patches im Puffer zu einem Array zusammenführen
    const allPatches = buffer.flatMap(item => item.patches);
    updateStatus(`📦 Verarbeite ${allPatches.length} Änderungen als Batch...`, '#FF9800');
    return allPatches;
  }),
  concatMap(patches => saveToServer(patches)), // Mit Reihenfolgegarantie senden
  catchError((error: unknown) => {
    console.error('Fehler beim automatischen Speichern:', error);
    updateStatus(`❌ Speichern fehlgeschlagen: ${(error instanceof Error ? error.message : String(error))}`, '#f44336');
    return of({ success: false, message: (error instanceof Error ? error.message : String(error)) });
  })
).subscribe(result => {
  if (result.success) {
    updateStatus(`✅ Automatisches Speichern abgeschlossen (${new Date().toLocaleTimeString()})`, '#4CAF50');
  }
  saveResult$.next(result);
});

// Speichern auf Server (Mock-Implementierung)
function saveToServer(patches: Operation[]): Promise<{ success: boolean; message: string }> {
  console.log('An Server senden:', patches);

  // Tatsächliche Implementierung Beispiel:
  // return fetch('/api/forms/12345/patches', {
  //   method: 'PATCH',
  //   headers: { 'Content-Type': 'application/json-patch+json' },
  //   body: JSON.stringify(patches)
  // }).then(res => res.json());

  // Mock: Erfolg nach 500ms zurückgeben
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        success: true,
        message: `${patches.length} Änderungen gespeichert`
      });
    }, 500);
  });
}

// Demo: Formularänderungen simulieren
const demoButton = document.createElement('button');
demoButton.textContent = 'Formular ändern (Demo)';
demoButton.style.padding = '10px 20px';
demoButton.style.margin = '10px';
demoButton.style.fontSize = '16px';
demoButton.style.cursor = 'pointer';
document.body.appendChild(demoButton);

demoButton.addEventListener('click', () => {
  // Zufällige Feldänderungen
  const currentData = formData$.getValue();
  const updatedData = {
    ...currentData,
    personalInfo: {
      ...currentData.personalInfo,
      firstName: `太郎_${Math.floor(Math.random() * 100)}`,
      email: `taro${Math.floor(Math.random() * 100)}@example.com`
    },
    preferences: {
      ...currentData.preferences,
      newsletter: !currentData.preferences.newsletter
    }
  };
  formData$.next(updatedData);
  updateStatus('📝 Formular wurde geändert...', '#2196F3');
});

Wichtige Punkte beim automatischen Speichern

  • bufferTime(2000) - Änderungen über 2 Sekunden zusammenfassen und senden (Netzwerkeffizienz)
  • concatMap - Garantiert die Reihenfolge der Patches (mergeMap kann die Reihenfolge durcheinanderbringen)
  • filter - Überspringen, wenn keine Änderungen vorliegen (unnötige Anfragen reduzieren)
  • Idempotenz - Sicher, auch wenn derselbe Patch mehrmals gesendet wird (Request-ID hinzufügen)

Muster 2: Undo/Redo-Implementierung ​

Implementieren Sie Undo/Redo-Funktionalität mit inversen Patches.

typescript
import { Subject, scan } from 'rxjs';
import { applyPatch, Operation, deepClone } from 'fast-json-patch';

interface HistoryState<T> {
  current: T;
  undoStack: Operation[][];
  redoStack: Operation[][];
}

interface HistoryAction {
  type: 'APPLY_PATCH' | 'UNDO' | 'REDO';
  patches?: Operation[];
}

// Stream zur Verlaufsverwaltung
const historyAction$ = new Subject<HistoryAction>();

const initialState: HistoryState<LargeFormData> = {
  current: initialFormData,
  undoStack: [],
  redoStack: []
};

// Reducer zur Verlaufsverwaltung
const history$ = historyAction$.pipe(
  scan((state, action) => {
    switch (action.type) {
      case 'APPLY_PATCH':
        if (!action.patches || action.patches.length === 0) return state;

        // Patch anwenden
        const cloned = deepClone(state.current);
        const result = applyPatch(cloned, action.patches, true, false);

        return {
          current: result.newDocument,
          undoStack: [...state.undoStack, action.patches],
          redoStack: [] // Redo-Stack bei neuer Operation löschen
        };

      case 'UNDO':
        if (state.undoStack.length === 0) return state;

        const patchesToUndo = state.undoStack[state.undoStack.length - 1];
        const beforeUndo = deepClone(state.current);

        // Inversen Patch generieren und anwenden
        const inversePatch = generateInversePatch(state.current, patchesToUndo);
        const undoResult = applyPatch(beforeUndo, inversePatch, true, false);

        return {
          current: undoResult.newDocument,
          undoStack: state.undoStack.slice(0, -1),
          redoStack: [...state.redoStack, patchesToUndo]
        };

      case 'REDO':
        if (state.redoStack.length === 0) return state;

        const patchesToRedo = state.redoStack[state.redoStack.length - 1];
        const beforeRedo = deepClone(state.current);
        const redoResult = applyPatch(beforeRedo, patchesToRedo, true, false);

        return {
          current: redoResult.newDocument,
          undoStack: [...state.undoStack, patchesToRedo],
          redoStack: state.redoStack.slice(0, -1)
        };

      default:
        return state;
    }
  }, initialState)
);

// Inverse Patch-Generierung (vereinfachte Implementierung)
function generateInversePatch(document: any, patches: Operation[]): Operation[] {
  // applyPatch von fast-json-patch gibt bei true als 4. Argument den inversen Patch zurück
  const cloned = deepClone(document);
  const result = applyPatch(cloned, patches, true, true);
  return result[1] || []; // Inversen Patch abrufen
}

// UI-Elemente
const historyControlDiv = document.createElement('div');
historyControlDiv.style.padding = '10px';
historyControlDiv.style.margin = '10px';
document.body.appendChild(historyControlDiv);

const undoButton = document.createElement('button');
undoButton.textContent = '↶ Rückgängig';
undoButton.style.padding = '10px 20px';
undoButton.style.marginRight = '10px';
undoButton.style.fontSize = '16px';
undoButton.style.cursor = 'pointer';
historyControlDiv.appendChild(undoButton);

const redoButton = document.createElement('button');
redoButton.textContent = '↷ Wiederholen';
redoButton.style.padding = '10px 20px';
redoButton.style.fontSize = '16px';
redoButton.style.cursor = 'pointer';
historyControlDiv.appendChild(redoButton);

const historyInfo = document.createElement('div');
historyInfo.style.marginTop = '10px';
historyInfo.style.fontFamily = 'monospace';
historyInfo.style.fontSize = '14px';
historyControlDiv.appendChild(historyInfo);

// Verlaufsstatus anzeigen
history$.subscribe(state => {
  undoButton.disabled = state.undoStack.length === 0;
  redoButton.disabled = state.redoStack.length === 0;

  historyInfo.innerHTML = `
    📚 Rückgängig möglich: ${state.undoStack.length} Mal<br>
    📚 Wiederholen möglich: ${state.redoStack.length} Mal<br>
    📝 Aktueller Wert: ${JSON.stringify(state.current.personalInfo.firstName)}
  `;

  // Formulardaten synchronisieren
  formData$.next(state.current);
});

// Button-Events
undoButton.addEventListener('click', () => {
  historyAction$.next({ type: 'UNDO' });
});

redoButton.addEventListener('click', () => {
  historyAction$.next({ type: 'REDO' });
});

// Demo: Patch-Anwendungsbutton
const applyPatchButton = document.createElement('button');
applyPatchButton.textContent = 'Änderung anwenden (Undo/Redo-Test)';
applyPatchButton.style.padding = '10px 20px';
applyPatchButton.style.margin = '10px';
applyPatchButton.style.fontSize = '16px';
applyPatchButton.style.cursor = 'pointer';
document.body.appendChild(applyPatchButton);

applyPatchButton.addEventListener('click', () => {
  const patches: Operation[] = [
    { op: 'replace', path: '/personalInfo/firstName', value: `太郎_${Date.now()}` }
  ];
  historyAction$.next({ type: 'APPLY_PATCH', patches });
});

Wichtige Punkte bei Undo/Redo

  • Inverse Patches - Das 4. Argument von applyPatch auf true setzen, um inverse Patches zu erhalten
  • Stack-Verwaltung - Undo-Stack (vergangene Operationen) und Redo-Stack (rückgängig gemachte Operationen)
  • Redo bei neuer Operation löschen - Redo-Stack bei neuen Änderungen zurücksetzen
  • Reducer-Muster mit scan - Zustandsverwaltung ähnlich wie useReducer in React

Muster 3: Offline-Unterstützung (IndexedDB-Warteschlange) ​

Warteschlange von Änderungen in IndexedDB bei Offline-Betrieb und Synchronisation bei Online-Wiederherstellung.

typescript
import { fromEvent, merge, map, filter, concatMap, catchError, of } from 'rxjs';

// Online/Offline-Status
const online$ = merge(
  fromEvent(window, 'online').pipe(map(() => true)),
  fromEvent(window, 'offline').pipe(map(() => false))
);

// IndexedDB-Operationen (vereinfachte Implementierung)
class PatchQueue {
  private dbName = 'form-patches';
  private storeName = 'patches';
  private db: IDBDatabase | null = null;

  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, {
            keyPath: 'id',
            autoIncrement: true
          });
        }
      };
    });
  }

  async enqueue(patches: Operation[]): Promise<void> {
    if (!this.db) throw new Error('DB not initialized');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.add({
        patches,
        timestamp: Date.now()
      });

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async dequeueAll(): Promise<Operation[][]> {
    if (!this.db) throw new Error('DB not initialized');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.getAll();

      request.onsuccess = () => {
        const items = request.result;
        // Nach Abruf löschen
        store.clear();
        resolve(items.map((item: any) => item.patches));
      };
      request.onerror = () => reject(request.error);
    });
  }
}

const patchQueue = new PatchQueue();
patchQueue.init().then(() => {
  console.log('IndexedDB initialisiert');
});

// Automatisches Speichern mit Offline-Unterstützung
formData$.pipe(
  pairwise(),
  map(([previous, current]) => compare(previous, current)),
  filter(patches => patches.length > 0),
  bufferTime(2000),
  filter(buffer => buffer.length > 0),
  map(buffer => buffer.flatMap(patches => patches)),
  concatMap(async (patches) => {
    const isOnline = navigator.onLine;

    if (isOnline) {
      // Online: An Server senden
      try {
        return await saveToServer(patches);
      } catch (error) {
        // Senden fehlgeschlagen: Zur Warteschlange hinzufügen
        await patchQueue.enqueue(patches);
        return { success: false, message: 'Zur Offline-Warteschlange hinzugefügt' };
      }
    } else {
      // Offline: Zur Warteschlange hinzufügen
      await patchQueue.enqueue(patches);
      console.log('📴 Offline: Zur Warteschlange hinzugefügt');
      return { success: false, message: 'Offline' };
    }
  })
).subscribe();

// Synchronisation bei Online-Wiederherstellung
online$.pipe(
  filter(isOnline => isOnline),
  concatMap(async () => {
    console.log('🌐 Online wiederhergestellt: Warteschlange wird synchronisiert...');
    const queuedPatches = await patchQueue.dequeueAll();

    for (const patches of queuedPatches) {
      await saveToServer(patches);
    }

    return { synced: queuedPatches.length };
  })
).subscribe(result => {
  console.log(`✅ ${result.synced} Patches synchronisiert`);
});

Wichtige Punkte bei Offline-Unterstützung

  • IndexedDB - Browser-seitige persistente Speicherung (größere Kapazität als LocalStorage)
  • Queueing - Bei Offline-Betrieb in Sendewarteschlange ansammeln
  • Online-Wiederherstellungserkennung - Event-Überwachung mit window.addEventListener('online')
  • Reihenfolgegarantie - Patches in der Warteschlange nacheinander mit concatMap senden

Echtzeit-Synchronisation für kollaborative Bearbeitung ​

Implementieren Sie „kollaborative Echtzeitbearbeitung", bei der mehrere Benutzer gleichzeitig dasselbe Formular bearbeiten (wie Google Docs, Notion, Figma).

Grundlagen von Operational Transform (OT) und CRDT ​

Bei kollaborativer Echtzeitbearbeitung ist die Konfliktlösung die größte Herausforderung. Es gibt zwei Hauptansätze.

Operational Transform (OT) ​

Lösen Sie Konflikte durch Transformation von Änderungsoperationen.

Benutzer A: "hello" → "hello world" (" world" am Ende hinzufügen)
Benutzer B: "hello" → "Hi hello"   ("Hi " am Anfang hinzufügen)

【Ohne Transformation】
Ergebnis: Entweder "Hi hello world" oder "hello world" (last write wins)

【Mit OT】
- Operation von Benutzer A mit Operation von Benutzer B transformieren
- Operation von Benutzer B mit Operation von Benutzer A transformieren
→ Ergebnis: "Hi hello world" (beide Änderungen beibehalten)

Vorteile:

  • Intuitive Ergebnisse (beide Änderungen beibehalten)
  • Server entscheidet über finalen Zustand

Nachteile:

  • Komplexe Implementierung
  • Server erforderlich

CRDT (Conflict-free Replicated Data Types) ​

Verwenden Sie mathematisch konfliktfreie Datenstrukturen.

Jedes Zeichen erhält eine eindeutige ID:

Benutzer A: [h1, e2, l3, l4, o5] → [h1, e2, l3, l4, o5, space6, w7, o8, r9, l10, d11]
Benutzer B: [h1, e2, l3, l4, o5] → [H12, i13, space14, h1, e2, l3, l4, o5]

Beim Merge nach ID sortieren:
→ [H12, i13, space14, h1, e2, l3, l4, o5, space6, w7, o8, r9, l10, d11]
→ "Hi hello world"

Vorteile:

  • Kein Server erforderlich (P2P möglich)
  • Einfache Offline-Unterstützung

Nachteile:

  • Höherer Speicherverbrauch
  • Komplexe Löschverarbeitung (Tombstone-Methode)

Bibliotheksauswahl

  • OT-Implementierung: ShareDB - Operational Transform
  • CRDT-Implementierung: Yjs - Hochleistungs-CRDT (empfohlen)
  • CRDT-Implementierung: Automerge - JSON-spezialisiert

Dieser Artikel zeigt Implementierungsbeispiele mit Yjs (CRDT).

Muster 4: Kollaborative Bearbeitung mit Yjs ​

Kombinieren Sie Yjs und RxJS für kollaborative Echtzeitbearbeitung.

typescript
import { fromEvent, merge, Subject } from 'rxjs';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// Yjs-Dokument (gemeinsamer Zustand)
const ydoc = new Y.Doc();

// Zu teilende Formulardaten (Yjs Map-Typ)
const yFormData = ydoc.getMap('formData');

// WebSocket-Provider (Serververbindung)
// Für Produktionsumgebung eigenen WebSocket-Server aufbauen
const wsProvider = new WebsocketProvider(
  'wss://demos.yjs.dev', // Öffentlicher Server für Demo
  'rxjs-form-demo',      // Raumname
  ydoc
);

// RxJS Subject für Formularänderungen
const formChange$ = new Subject<{
  key: string;
  value: any;
  user: string;
}>();

// Yjs-Änderungen in RxJS-Stream umwandeln
yFormData.observe(event => {
  event.changes.keys.forEach((change, key) => {
    if (change.action === 'add' || change.action === 'update') {
      formChange$.next({
        key: key,
        value: yFormData.get(key),
        user: 'remote'
      });
    }
  });
});

// Demo-UI
const collaborativeFormDiv = document.createElement('div');
collaborativeFormDiv.style.padding = '20px';
collaborativeFormDiv.style.margin = '10px';
collaborativeFormDiv.style.border = '2px solid #2196F3';
collaborativeFormDiv.style.borderRadius = '8px';
collaborativeFormDiv.style.backgroundColor = '#f5f5f5';
document.body.appendChild(collaborativeFormDiv);

const title = document.createElement('h3');
title.textContent = '🤝 Kollaborative Echtzeitbearbeitung Demo';
title.style.margin = '0 0 15px 0';
collaborativeFormDiv.appendChild(title);

const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Name eingeben (wird mit anderen Benutzern synchronisiert)';
nameInput.style.padding = '10px';
nameInput.style.width = '100%';
nameInput.style.fontSize = '16px';
nameInput.style.border = '2px solid #ccc';
nameInput.style.borderRadius = '4px';
nameInput.style.boxSizing = 'border-box';
collaborativeFormDiv.appendChild(nameInput);

const syncStatus = document.createElement('div');
syncStatus.style.marginTop = '10px';
syncStatus.style.fontSize = '14px';
syncStatus.style.color = '#666';
collaborativeFormDiv.appendChild(syncStatus);

const activeUsers = document.createElement('div');
activeUsers.style.marginTop = '10px';
activeUsers.style.fontSize = '14px';
activeUsers.style.color = '#666';
collaborativeFormDiv.appendChild(activeUsers);

// Verbindungsstatus überwachen
wsProvider.on('status', (event: { status: string }) => {
  if (event.status === 'connected') {
    syncStatus.innerHTML = '🟢 <strong>Verbunden</strong> - Echtzeit-Synchronisation mit anderen Benutzern';
    syncStatus.style.color = '#4CAF50';
  } else {
    syncStatus.innerHTML = '🔴 <strong>Getrennt</strong>';
    syncStatus.style.color = '#f44336';
  }
});

// Anzahl verbundener Benutzer anzeigen (Awareness API)
wsProvider.awareness.on('change', () => {
  const users = Array.from(wsProvider.awareness.getStates().keys());
  activeUsers.innerHTML = `👥 Aktive Benutzer: ${users.length} Person(en)`;
});

// Lokale Änderungen in Yjs übernehmen
let isRemoteChange = false;

fromEvent(nameInput, 'input').subscribe(() => {
  if (!isRemoteChange) {
    yFormData.set('name', nameInput.value);
  }
});

// Remote-Änderungen in UI übernehmen
formChange$.subscribe(change => {
  if (change.key === 'name') {
    isRemoteChange = true;
    nameInput.value = change.value || '';
    nameInput.style.borderColor = '#4CAF50';

    setTimeout(() => {
      nameInput.style.borderColor = '#ccc';
      isRemoteChange = false;
    }, 500);
  }
});

// Initialwert setzen
const initialName = yFormData.get('name');
if (initialName) {
  nameInput.value = initialName;
}

Yjs-Wichtigkeiten

  • Y.Doc - Gemeinsames Dokument (CRDT)
  • Y.Map - Gemeinsamer Map-Typ ({ key: value })
  • WebsocketProvider - Synchronisation über WebSocket
  • Awareness API - Präsenzinformationen (verbundene Benutzer, Cursorposition)
  • observe - Änderungen überwachen und in RxJS-Stream umwandeln

Muster 5: Präsenzverwaltung (Cursorposition teilen) ​

Visualisieren Sie, wer wo bearbeitet.

typescript
import { throttleTime } from 'rxjs';

interface UserPresence {
  userId: string;
  name: string;
  color: string;
  cursorPosition: number;
  timestamp: number;
}

// Zufällige Farbe generieren
function generateRandomColor(): string {
  const colors = ['#f44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#00BCD4', '#009688'];
  return colors[Math.floor(Math.random() * colors.length)];
}

// Eigene Benutzer-ID (zufällig generiert)
const myUserId = `user_${Math.random().toString(36).substr(2, 9)}`;
const myColor = generateRandomColor();

// Awareness (Präsenzinformationen) konfigurieren
wsProvider.awareness.setLocalState({
  userId: myUserId,
  name: `Benutzer${myUserId.slice(-4)}`,
  color: myColor
});

// Cursorpositionsänderung erkennen (Sendefrequenz mit throttle begrenzen)
fromEvent(nameInput, 'selectionchange').pipe(
  throttleTime(200)
).subscribe(() => {
  const cursorPosition = nameInput.selectionStart || 0;

  wsProvider.awareness.setLocalStateField('cursorPosition', cursorPosition);
});

// Cursorpositionen anderer Benutzer anzeigen
const cursorOverlay = document.createElement('div');
cursorOverlay.style.position = 'relative';
cursorOverlay.style.marginTop = '10px';
cursorOverlay.style.fontSize = '12px';
collaborativeFormDiv.appendChild(cursorOverlay);

wsProvider.awareness.on('change', () => {
  const states = wsProvider.awareness.getStates();
  const cursors: string[] = [];

  states.forEach((state: any, clientId: number) => {
    if (state.userId !== myUserId) {
      cursors.push(
        `<span style="color: ${state.color}">● ${state.name}</span> (Position: ${state.cursorPosition || 0})`
      );
    }
  });

  cursorOverlay.innerHTML = cursors.length > 0
    ? `📍 Andere Benutzer: ${cursors.join(', ')}`
    : '📍 Keine anderen Benutzer';
});

Wichtige Punkte zur Präsenzverwaltung

  • awareness.setLocalState - Eigene Informationen teilen
  • awareness.getStates - Informationen aller Benutzer abrufen
  • throttleTime - Sendefrequenz der Cursorbewegungen begrenzen (200ms)
  • Farbcodierung - Farbe pro Benutzer zuweisen für bessere Sichtbarkeit

Muster 6: Fehlerbehandlung und Wiederverbindung ​

Implementieren Sie Wiederverbindung und Fehlerbehandlung bei WebSocket-Trennung.

typescript
import { timer, takeUntil, Subject } from 'rxjs';

const disconnect$ = new Subject<void>();

// WebSocket-Trennung erkennen
wsProvider.on('connection-close', () => {
  console.warn('⚠️ WebSocket getrennt');
  syncStatus.innerHTML = '🟡 <strong>Wiederverbindung läuft...</strong>';
  syncStatus.style.color = '#FF9800';

  // Wiederverbindung nach 5 Sekunden versuchen
  timer(5000).pipe(
    takeUntil(disconnect$)
  ).subscribe(() => {
    console.log('🔄 Versuche Wiederverbindung');
    wsProvider.connect();
  });
});

// WebSocket-Fehlerbehandlung
wsProvider.on('connection-error', (error: Error) => {
  console.error('❌ WebSocket-Fehler:', error);
  syncStatus.innerHTML = `❌ <strong>Fehler:</strong> ${error.message}`;
  syncStatus.style.color = '#f44336';
});

// Bereinigung
window.addEventListener('beforeunload', () => {
  disconnect$.next();
  wsProvider.disconnect();
  ydoc.destroy();
});

Hinweise für Produktionsumgebung

  • Eigener WebSocket-Server - wss://demos.yjs.dev ist für Entwicklung. Für Produktion y-websocket-server aufbauen
  • Authentifizierung - Token-Authentifizierung bei WebSocket-Verbindung implementieren
  • Skalierung - Zustand zwischen WebSocket-Servern über Redis etc. teilen
  • Persistierung - Yjs-Dokument in Datenbank speichern (y-leveldb, y-indexeddb)

Zusammenfassung ​

Dieser Artikel hat fortgeschrittene Formularmuster mit JSON Patch erklärt.

Wichtige Punkte ​

Auswahlkriterien für JSON Patch-Muster

Wenn normale Formularverarbeitung ausreicht:

  • Feldanzahl: ~20 Felder
  • Automatisches Speichern: Nicht erforderlich oder Gesamtsendung OK
  • Undo/Redo: Nicht erforderlich
  • Kollaborative Bearbeitung: Nicht erforderlich → Verwenden Sie normale Formularverarbeitungsmuster

Wenn JSON Patch erforderlich ist:

  • Feldanzahl: 100+ Felder
  • Automatisches Speichern: Erforderlich (nur Differenzen senden)
  • Undo/Redo: Erforderlich
  • Kollaborative Bearbeitung: Echtzeit-Synchronisation erforderlich → Verwenden Sie die Muster aus diesem Artikel

Zusammenfassung der Implementierungsmuster ​

MusterAnwendungsfallHaupttechnologien
Grundlegendes automatisches SpeichernDifferenzsendung für große Formularepairwise + bufferTime + concatMap
Undo/RedoVerlaufsverwaltungInverse Patches + scan
Offline-UnterstützungNetzwerkausfall-BehandlungIndexedDB + Queueing
Kollaborative Bearbeitung (Yjs)Echtzeit-SynchronisationYjs (CRDT) + WebSocket
PräsenzverwaltungCursorposition teilenAwareness API + throttleTime

Nächste Schritte ​

Referenzressourcen ​

Standardspezifikationen ​

Bibliotheken ​

  • fast-json-patch - JSON Patch-Implementierung (RFC-konform)
  • Yjs - CRDT-Implementierung (kollaborative Bearbeitung)
  • ShareDB - Operational Transform-Implementierung
  • Automerge - JSON-spezialisiertes CRDT

Lernressourcen ​

Veröffentlicht unter CC-BY-4.0-Lizenz.