Blog von Obi Madu
Zurück zu allen Artikeln
System DesignDesktopBackendFrontend

Eine Codebase, Web und Desktop

Wie man eine Tauri-App so designt, dass derselbe React-Code im Browser und auf dem Desktop läuft, indem man die Runtime-Unterschiede hinter einem Service-Interface versteckt.

Eine Codebase, Web und Desktop

Wenn du die Tauri-Einführung gelesen hast, weißt du, was Tauri ist und wie es funktioniert: eine native Desktop-App, bei der die UI aus Web-Technologie besteht und ein Rust-Backend die Betriebssystem-Seite über IPC übernimmt. Die nächste Frage, die normalerweise auftaucht, ist: wie schreibt man eine Codebase, die sowohl dort als auch in einem normalen Browser läuft?

Diese Frage taucht früh auf. Deine React-Komponenten müssen Dateien lesen und schreiben, Benachrichtigungen anzeigen und mit dem Betriebssystem kommunizieren. In Tauri läuft all das über invoke() und das Rust-Backend. Aber man möchte dasselbe Frontend auch in einem Browser während der Entwicklung laufen lassen oder es als Web-App neben der Desktop-Version ausliefern. Im Browser macht invoke() nichts. Es gibt kein Rust am anderen Ende.

Der naive Ansatz ist es, isTauri()-Prüfungen überall in den Komponenten zu verstreuen. Eine Komponente ruft invoke() auf, wenn sie in Tauri läuft, und fetch(), wenn sie im Browser läuft. Eine andere macht dasselbe für Benachrichtigungen. Eine dritte für Dateispeicherung. Irgendwann weiß jede Komponente über beide Runtimes Bescheid, und der Code ist voll von Verzweigungen, die je nach Ausführungsort unterschiedliche Dinge tun.

Das funktioniert, aber es skaliert schlecht. Jede neue Komponente, die eine native Fähigkeit braucht, muss dieselbe Lektion lernen. Jede Komponente trägt das Gewicht von zwei Runtimes. Und sobald man eine dritte Runtime hinzufügen will, etwa einen Mobile-Build, muss man wieder Komponenten editieren.

Es gibt dafür eine bessere Form.

Die Runtime hinter ein Interface setzen

Das Muster besteht darin, den Komponenten das Wissen über die Runtime zu nehmen. Stattdessen definiert man ein Interface und tauscht die Implementierung je nach Ausführungsort aus.

Nimm Datei-Operationen. Deine Komponenten müssen Dokumente speichern, Dateien lesen und Verzeichnisse auflisten. Definiere, wie das aussieht, ohne sich darum zu kümmern, wie es passiert:

interface FileService {
  saveDocument(doc: Document): Promise<void>;
  getFiles(): Promise<string[]>;
  readFile(path: string): Promise<string>;
}

Deine React-Komponenten hängen von diesem Interface ab. Sie rufen niemals invoke() auf. Sie rufen niemals fetch() auf. Sie rufen fileService.saveDocument(doc) auf und machen weiter.

function SaveButton({ doc }: { doc: Document }) {
  const fileService = useFileService();

  return (
    <button onClick={() => fileService.saveDocument(doc)}>
      Save
    </button>
  );
}

Die Komponente weiß nicht, ob dieses Speichern zu Rust, zu einer HTTP-API oder zu einem Mock in einem Test geht. Sie muss es nicht wissen.

Zwei Implementierungen

Man schreibt zwei Implementierungen dieses Interfaces. Eine für den Browser, eine für Tauri.

Die Browser-Version spricht mit dem Backend über HTTP:

class WebFileService implements FileService {
  async saveDocument(doc: Document): Promise<void> {
    await fetch("/api/documents", {
      method: "POST",
      body: JSON.stringify(doc),
    });
  }

  async getFiles(): Promise<string[]> {
    const res = await fetch("/api/files");
    return res.json();
  }

  async readFile(path: string): Promise<string> {
    const res = await fetch(`/api/files/${encodeURIComponent(path)}`);
    return res.text();
  }
}

Die Tauri-Version sendet Nachrichten an Rust über invoke():

class TauriFileService implements FileService {
  async saveDocument(doc: Document): Promise<void> {
    await invoke("save_document", { doc });
  }

  async getFiles(): Promise<string[]> {
    return invoke<string[]>("get_files");
  }

  async readFile(path: string): Promise<string> {
    return invoke<string>("read_file", { path });
  }
}

Gleiches Interface, darunter zwei komplett unterschiedliche Mechanismen. Einer geht über das Netzwerk. Der andere geht über IPC zu einer kompilierten Rust-Funktion, die auf das echte Dateisystem schreibt.

Am Boundary eine auswählen

Man wählt die Implementierung einmal aus, am Boundary, wo die App startet, und alles dahinter bekommt die richtige:

function createFileService(): FileService {
  if (isTauri()) {
    return new TauriFileService();
  }
  return new WebFileService();
}

Oder mit einem Provider, wenn man React Context bevorzugt:

const FileServiceContext = createContext<FileService>(
  isTauri() ? new TauriFileService() : new WebFileService()
);

function useFileService(): FileService {
  return useContext(FileServiceContext);
}

Jetzt bekommt jede Komponente in der App die richtige Implementierung, ohne jemals zu wissen, welche es ist. Die isTauri()-Prüfung passiert an einer Stelle. Die Komponenten sind sauber.

Was geteilt wird, was getauscht wird

Dieses Muster funktioniert, weil der Großteil der Anwendung sich nicht um die Runtime kümmert. Die UI, das State-Management, die Business-Regeln, die Validierung, das Routing, der Komponenten-Baum, all das ist identisch, unabhängig davon, wo die App läuft.

GeteiltGetauscht
KomponentenDateizugriff
State-ManagementBenachrichtigungen
Business-RegelnOS-Integrationen
ValidierungAuthentifizierungsmethode
RoutingSpeicher
Komponenten-BaumNetzwerkschicht

Die geteilte Spalte ist der Großteil deiner Codebase. Die getauschte Spalte ist die Oberfläche, wo deine App die Außenwelt berührt, und diese Oberfläche ist, wo die Runtime tatsächlich zählt. Indem man jedes getauschte Stück hinter ein Interface setzt, behält man die Runtime-Unterschiede an einem Ort, statt über jede Komponente verstreut.

Warum dies das richtige Muster für Tauri ist

Tauri gibt dir das nicht kostenlos. Wenn man einfach anfängt, invoke() in den Komponenten aufzurufen, erhält man eine Desktop-App, die zufällig Web-Code enthält. Sie wird nicht mehr im Browser laufen, weil jede Komponente davon abhängt, dass die Rust-Bridge vorhanden ist. Man hat sein Frontend an die Desktop-Runtime gebunden.

Das Interface-Muster kehrt das um. Man erhält eine Web-App, die eine Desktop-App werden kann, weil die Web-App ohne das Wissen über die Desktop-Runtime gebaut wurde. Die Desktop-Fähigkeiten wurden an den Boundaries hinzugefügt, und die Kern-Anwendung hing nie von ihnen ab.

Diese Unterscheidung zählt für mehr als nur das Laufen im Browser. Sie bedeutet, dass man Komponenten isoliert testen kann, weil sie von einem Interface abhängen, nicht von invoke(). Sie bedeutet, dass man eine neue Runtime hinzufügen kann, ohne Komponenten anzufassen, indem man eine neue Implementierung des Interfaces schreibt. Sie bedeutet, dass die Entscheidung "ist das eine Web-App oder eine Desktop-App" eine Konfigurationsentscheidung ist, keine strukturelle.

Ein echtes Beispiel

Man baut eine Notiz-App. Im Browser werden Notizen ans Backend-API gespeichert. In Tauri werden Notizen über Rust ans lokale Dateisystem gespeichert. Die UI ist in beiden gleich: eine Liste von Notizen, ein Editor, ein Speichern-Button.

                    SaveButton
                        |
                  FileService (Interface)
                   /            \
                  /              \
    WebFileService          TauriFileService
         |                        |
    HTTP POST /api/notes    invoke("save_note")
         |                        |
    Backend-Server            Rust-Funktion
         |                        |
    Datenbank                 Lokales Dateisystem

Der SaveButton weiß nicht, auf welcher Seite er ist. Der Editor weiß es nicht. Die NoteList weiß es nicht. Das Einzige, was es weiß, ist die eine Code-Zeile, die die Implementierung beim App-Start auswählt.

Wenn der Nutzer im Browser auf Speichern klickt, geht die Anfrage ans Backend. Wenn er in der Desktop-App auf Speichern klickt, geht die Anfrage zu Rust, welches auf die Festplatte schreibt. Der Komponenten-Code ist identisch. Die Erfahrung ist unterschiedlich. Der Speicherort ist unterschiedlich. Die Vertrauensgrenze ist unterschiedlich. Aber der React-Code, mit dem der Nutzer interagiert, ist derselbe.

Wo man die Linien zieht

Die Interfaces, die man definiert, sollten den Bedürfnissen der Anwendung entsprechen, nicht den Fähigkeiten der Runtime. Erstelle kein TauriService, das jeden invoke()-Aufruf wrappt. Das verlagert nur das Problem. Erstelle ein FileService für Datei-Operationen, ein NotificationService für Benachrichtigungen, ein AuthService für Authentifizierung. Jedes Interface beschreibt, was die Anwendung tun muss, und jede Runtime liefert ihre eigene Implementierung dieses Bedarfs.

Die Frage, die man sich stellen sollte, wenn man entscheidet, ob etwas hinter ein Interface gehört, ist: funktioniert das im Browser anders als in Tauri? Wenn ja, gehört es hinter ein Interface. Wenn nein, ist es geteilter Code und beide Runtimes nutzen dieselbe Implementierung.

Dateizugriff ist unterschiedlich. Benachrichtigungen sind unterschiedlich. OS-Integrationen sind unterschiedlich. Formularvalidierung ist gleich. State-Management ist gleich. Routing ist gleich. Die Linie wird dort gezogen, wo die Runtime tatsächlich divergiert, und einmal gezogen, lebt die Divergenz an einem Ort.