Direkte, synchrone C++-Bindings ohne Bridge schreiben
Das JavaScript Interface, JSI, verbindet JavaScript Runtime und nativen C++ Code direkt, ganz ohne JSON-Serialisierung oder asynchronen Bridge-Round-Trip. Wer ein eigenes HostObject baut, lernt, wie synchrone native Bindings in der Praxis funktionieren, warum der Performance-Gewinn vor allem bei häufigen Aufrufen zählt und wo die Grenzen dieses Ansatzes liegen.
Inhaltsverzeichnis
- 1. Warum die klassische Bridge für viele Anwendungsfälle zu langsam war
- 2. Was JSI wirklich ist: ein C++ Interface zwischen Runtime und nativem Code
- 3. HostObject: eigene Objekte direkt in der JS-Runtime bereitstellen
- 4. Praktisches Beispiel: eine synchrone HostObject-Implementierung
- 5. Installation: JSI-Module über TurboModuleManager oder eigenen Installer registrieren
- 6. Speicherverwaltung: shared_ptr, Runtime-Lebenszyklus und GC-Interop
- 7. Performance in der Praxis: Bridge gegen JSI bei häufigen Aufrufen
- 8. Grenzen von JSI: Thread-Safety und wann Async weiterhin sinnvoll bleibt
- 9. Debugging von JSI-Code: native Crashes und Stack Traces
- 10. Zusammenfassung
- 11. FAQ
1. Warum die klassische Bridge für viele Anwendungsfälle zu langsam war
In der ursprünglichen React Native Architektur liefen alle Aufrufe zwischen JavaScript und nativem Code über die Bridge, einen asynchronen Kanal, der jeden Aufruf inklusive Argumenten in ein JSON-kompatibles Format serialisierte, batchte und über eine Message Queue an den jeweils anderen Thread schickte. Für seltene Aufrufe wie einen einmaligen API-Request war das unproblematisch, aber für Funktionen, die hunderte Male pro Sekunde aufgerufen werden, etwa Animationswerte oder Sensordaten, summierte sich der Overhead aus Serialisierung, Queueing und Thread-Wechsel spürbar auf.
Ein zweites strukturelles Problem war, dass die Bridge grundsätzlich asynchron war. Selbst eine triviale, synchrone Berechnung auf nativer Seite musste als Promise oder Callback modelliert werden, was im JavaScript-Code unnötige Komplexität erzeugte und echte synchrone APIs, wie sie native Plattform-SDKs oft anbieten, praktisch unmöglich machte, ohne auf riskante Workarounds wie NativeModules mit synchronen Methoden zurückzugreifen, die auf Android ohnehin nur eingeschränkt funktionierten.
2. Was JSI wirklich ist: ein C++ Interface zwischen Runtime und nativem Code
Das JavaScript Interface, kurz JSI, ist keine neue Bridge, sondern eine C++ Abstraktionsschicht, die es nativem Code erlaubt, direkt mit der JavaScript Runtime zu sprechen, egal ob diese Hermes, JavaScriptCore oder eine andere JSI-kompatible Engine ist. Statt Daten zu serialisieren, hält JSI Referenzen auf echte JavaScript-Objekte und -Funktionen im Speicher und erlaubt nativem C++ Code, diese Objekte direkt zu lesen, zu verändern oder aufzurufen, so wie es JavaScript selbst auch tun würde.
Dadurch werden synchrone Aufrufe erstmals strukturell möglich: Eine C++ Funktion kann von JavaScript aus aufgerufen werden und sofort einen Wert zurückgeben, ganz ohne Promise, Callback oder Bridge-Round-Trip. Das ist die technische Grundlage, auf der sowohl TurboModules als auch Fabric aufbauen, aber JSI lässt sich auch völlig unabhängig davon nutzen, um eigene, hochperformante native Bindings zu schreiben.
3. HostObject: eigene Objekte direkt in der JS-Runtime bereitstellen
Der zentrale Baustein für eigene JSI-Bindings ist die Klasse jsi::HostObject. Wer sie erbt und die Methoden get und set überschreibt, kann ein C++ Objekt in die JavaScript Runtime einhängen, das sich für JavaScript-Code wie ein ganz normales Objekt verhält, dessen Properties und Methoden aber tatsächlich in C++ ausgeführt werden. Jeder Property-Zugriff aus JavaScript ruft dabei synchron die entsprechende C++ Methode auf.
Das eignet sich besonders für Fälle, in denen native Berechnungen oder native Zustandsabfragen ohne spürbare Latenz aus JavaScript heraus verfügbar sein müssen, etwa ein Kryptographie-Modul, ein Bildverarbeitungs-Kernel oder eine performante Datenstruktur wie ein Ringpuffer für Sensordaten. Anders als bei einem klassischen NativeModule entsteht dabei kein zusätzlicher Bridge-Call pro Zugriff.
4. Praktisches Beispiel: eine synchrone HostObject-Implementierung
Ein einfaches Beispiel ist ein Zähler, der komplett in C++ lebt und aus JavaScript synchron gelesen und erhöht werden kann. Im Konstruktor wird eine get-Methode implementiert, die auf den angefragten Property-Namen reagiert, sowie optional eine set-Methode, falls Schreibzugriffe erlaubt sein sollen. Die Installation erfolgt über eine globale Property auf dem jsi::Runtime-Objekt, meist beim App-Start.
Das folgende Beispiel zeigt eine minimale HostObject-Klasse mit einer lesbaren Property und einer aufrufbaren Methode, jeweils direkt in C++ implementiert, ganz ohne Bridge-Aufruf im Hintergrund.
class CounterHostObject : public jsi::HostObject {
public:
explicit CounterHostObject(int start) : value_(start) {}
jsi::Value get(jsi::Runtime &rt, const jsi::PropNameID &name) override {
auto propName = name.utf8(rt);
if (propName == "value") {
return jsi::Value(value_);
}
if (propName == "increment") {
return jsi::Function::createFromHostFunction(
rt, name, 0,
[this](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *, size_t) {
value_ += 1;
return jsi::Value(value_);
});
}
return jsi::Value::undefined();
}
private:
int value_;
};
// Installation beim App-Start, z.B. in einem eigenen JSI Installer
void installCounter(jsi::Runtime &runtime) {
auto counter = std::make_shared<CounterHostObject>(0);
runtime.global().setProperty(
runtime, "NativeCounter",
jsi::Object::createFromHostObject(runtime, counter));
}
5. Installation: JSI-Module über TurboModuleManager oder eigenen Installer registrieren
Für produktive Projekte lohnt es sich, ein eigenes JSI-Modul entweder über den ohnehin vorhandenen TurboModule-Mechanismus zu registrieren, wenn es sich in das reguläre Modul-System einfügt, oder über einen eigenen, minimalen Installer, der direkt beim Erzeugen der JavaScript Runtime aufgerufen wird. Letzteres ist üblich für sehr früh benötigte, plattformübergreifende Low-Level-Bindings, etwa Logging oder Performance-Messungen, die noch vor dem Laden des eigentlichen JavaScript-Bundles verfügbar sein müssen.
Wichtig ist, den Installer sowohl in der iOS- als auch in der Android-Runtime-Initialisierung einzuhängen, da beide Plattformen getrennte Einstiegspunkte für die Runtime-Erzeugung haben. Wer das vergisst, bekommt auf einer Plattform einen undefined-Fehler beim ersten Zugriff auf das globale Objekt, was ohne Kenntnis der Installationsreihenfolge schwer zu debuggen ist.
6. Speicherverwaltung: shared_ptr, Runtime-Lebenszyklus und GC-Interop
JSI-Objekte leben in zwei Welten gleichzeitig: als C++ Objekte mit klassischer, referenzgezählter Lebensdauer über std::shared_ptr, und als vom JavaScript-Garbage-Collector verwaltete Werte innerhalb der Runtime. Ein HostObject wird so lange am Leben gehalten, wie mindestens eine JavaScript-Referenz darauf existiert, weshalb zyklische Referenzen zwischen HostObjects und JavaScript-Funktionen zu Leaks führen können, wenn nicht bewusst mit schwachen Referenzen gearbeitet wird.
Ein häufiger Fehler ist, in einem HostFunction-Lambda eine this-Referenz per Kopie statt per shared_ptr zu halten, wodurch das zugrunde liegende C++ Objekt schon zerstört sein kann, während JavaScript noch eine gültige Referenz darauf hält. Solche Bugs äußern sich meist als sporadischer Absturz weit entfernt vom eigentlichen Fehlerort und sind ohne saubere Ownership-Struktur schwer zu reproduzieren.
7. Performance in der Praxis: Bridge gegen JSI bei häufigen Aufrufen
Der Performance-Unterschied zwischen Bridge und JSI zeigt sich am deutlichsten bei Funktionen, die sehr häufig mit kleinen Datenmengen aufgerufen werden. Ein Bridge-Aufruf kostet, unabhängig von der eigentlichen Nutzlast, immer den Overhead aus JSON-Serialisierung, Queueing und mindestens einem Thread-Wechsel, während ein JSI-Aufruf über ein HostObject nur die eigentliche Funktionsausführung kostet, da kein Datenformat konvertiert werden muss.
Bei tausenden Aufrufen pro Sekunde, etwa bei einer Animationsschleife, die pro Frame mehrere native Werte abfragt, macht das den Unterschied zwischen einer flüssigen 60-Hertz-Animation und spürbaren Rucklern aus. Für seltene Aufrufe, etwa einen einmaligen Dateizugriff, ist der Unterschied dagegen kaum messbar, weshalb sich JSI vor allem für Hot-Path-Code lohnt, nicht für jede beliebige native Anbindung.
8. Grenzen von JSI: Thread-Safety und wann Async weiterhin sinnvoll bleibt
JSI-Aufrufe laufen synchron auf dem Thread, von dem aus sie aufgerufen werden, meist dem JavaScript-Thread. Das bedeutet, dass eine lang laufende Operation innerhalb eines HostObjects den kompletten JavaScript-Thread blockiert und damit auch das UI einfrieren lässt, wenn diese Operation vom Hauptthread aus getriggert wird. Für rechenintensive Aufgaben wie große Bildverarbeitung bleibt deshalb ein eigener Hintergrund-Thread mit asynchroner Rückmeldung die richtige Wahl, nicht ein direkter synchroner JSI-Aufruf.
Zusätzlich ist der Zugriff auf ein jsi::Runtime-Objekt selbst nicht threadsicher, da die Runtime für genau einen Thread konzipiert ist. Wer aus einem Hintergrund-Thread heraus Werte an JavaScript zurückgeben will, muss diesen Wechsel explizit über einen dafür vorgesehenen Mechanismus wie runOnJSQueueThread vornehmen, statt die Runtime direkt aus einem beliebigen Thread anzusprechen.
9. Debugging von JSI-Code: native Crashes und Stack Traces
Fehler in JSI-Code äußern sich häufig nicht als übliche JavaScript-Exception, sondern als nativer Absturz mit einem C++ Stack Trace, der ohne Symbolisierung schwer lesbar ist. Für iOS hilft ein an Xcode angehängter Debugger mit aktivierten Debug-Symbolen, für Android liefert der native Crash-Handler in Verbindung mit ndk-stack oder Symbolisierung über den Play Console Crash-Report brauchbare Stack Traces.
Eine typische Fehlerklasse sind ungültige Typumwandlungen, etwa der Versuch, einen JSI-Wert als Zahl zu lesen, obwohl JavaScript tatsächlich undefined übergeben hat. Da JSI bewusst wenig automatische Fehlerbehandlung bietet, um Overhead zu vermeiden, lohnt es sich, an den Grenzen eigener HostObjects konsequent den tatsächlichen Werttyp zu prüfen, bevor er weiterverarbeitet wird, statt auf eine implizite Ausnahme zu vertrauen.
| Kriterium | Klassische Bridge | JSI HostObject | Praktische Konsequenz |
|---|---|---|---|
| Aufrufart | Ausschließlich asynchron | Synchron möglich | Sofortige Rückgabewerte ohne Promise |
| Datenübergabe | JSON-Serialisierung | Direkte Objektreferenzen | Kein Overhead durch Konvertierung |
| Threading | Bridge-Queue auf separatem Thread | Läuft auf dem aufrufenden Thread | Lange Operationen blockieren bei Fehlgebrauch die UI |
| Typsicherheit | Lose, laufzeitbasiert | Explizite jsi::Value Typprüfung nötig | Entwickler muss Typen selbst absichern |
| Einsatzgebiet | Seltene, unkritische Aufrufe | Hochfrequente Hot-Path-Aufrufe | JSI lohnt sich nicht für jede Anbindung |
Mironsoft
React-Native-App-Entwicklung und Magento-Anbindung
Eine mobile App zum Magento-Shop, die wirklich rund läuft?
Wir entwickeln React-Native-Apps, die sauber an die Magento REST- oder GraphQL-API angebunden sind, von der ersten Codezeile bis zur Veröffentlichung im App Store und bei Google Play.
App-Konzeption
Architektur und Feature-Umfang einer Magento-angebundenen App gemeinsam planen.
Magento-API-Integration
Produktkatalog, Warenkorb und Checkout sauber an die Shop-API anbinden.
Store-Veröffentlichung
App Store- und Google-Play-Freigabeprozess ohne Stolperfallen begleiten.
10. Zusammenfassung
JSI Direkte Bindings: Das Wichtigste auf einen Blick
JSI im Kern
Ein C++ Interface, das JavaScript Runtime und nativen Code ohne Serialisierung direkt verbindet.
HostObject
Erlaubt eigene C++ Objekte, die sich für JavaScript wie normale Objekte mit synchronen Methoden verhalten.
Performance
Lohnt sich vor allem bei sehr häufigen Aufrufen mit kleiner Nutzlast, kaum messbar bei seltenen Aufrufen.
Grenzen
Synchron blockiert den aufrufenden Thread, lang laufende Arbeit gehört weiterhin in einen Hintergrund-Thread.