UI-Thread und JS-Thread sauber trennen
Worklets sind das Fundament von Reanimated: kleine JavaScript-Funktionen, die per Babel-Plugin so transformiert werden, dass sie direkt auf dem UI-Thread laufen, unabhängig von der Auslastung des JS-Thread. Wer verstehen will, warum Reanimated so performant ist und wo runOnJS sowie runOnUI wirklich hingehören, muss dieses Ausführungsmodell im Detail kennen, nicht nur die fertigen Hooks.
Inhaltsverzeichnis
- 1. Zwei Ausführungskontexte, ein Rendering-Ziel
- 2. Die worklet-Direktive und das Babel-Plugin
- 3. Was beim Kompilieren eines Worklets technisch passiert
- 4. Closures in Worklets: Werte werden kopiert, nicht referenziert
- 5. runOnJS: sicher vom UI-Thread zurück zum JS-Thread wechseln
- 6. runOnUI: gezielt Code auf den UI-Thread schicken
- 7. Typische Fehler: JS-Werte im Worklet lesen
- 8. Worklets debuggen: Logger, Sourcemaps und Fehleranalyse
- 9. Praxis-Pattern: Scroll-Handler ganz ohne Bridge-Overhead
- 10. Zusammenfassung
- 11. FAQ
1. Zwei Ausführungskontexte, ein Rendering-Ziel
React Native trennt seit jeher die App-Logik vom eigentlichen Zeichnen der Oberfläche. Der JS-Thread führt Komponenten-Rendering, State-Updates und Business-Logik aus, während der UI-Thread (auf iOS der Main-Thread, auf Android der native UI-Thread) für das tatsächliche Layout und Zeichnen der nativen Views zuständig ist. Vor JSI musste jede Kommunikation zwischen beiden Seiten über die asynchrone, serialisierende Bridge laufen, was für Animationen mit 60 Bildern pro Sekunde ein spürbares Zeitbudget-Problem war.
Reanimated löst dieses Problem, indem Animationslogik nicht mehr als serialisierte Nachricht über die Bridge geschickt wird, sondern als kompilierter Code direkt auf dem UI-Thread ausgeführt wird. Das gelingt über JSI, das synchrone Funktionsaufrufe zwischen JavaScript und nativem Code erlaubt. Der Effekt: Eine Animation bleibt flüssig, selbst wenn der JS-Thread gerade eine große Liste neu rendert oder eine teure Berechnung durchführt, weil der UI-Thread davon komplett entkoppelt ist.
2. Die worklet-Direktive und das Babel-Plugin
Eine Funktion wird zum Worklet, indem sie entweder implizit von einer Reanimated-API wie useAnimatedStyle behandelt wird oder explizit mit der Direktive 'worklet' als erster Zeile im Funktionskörper markiert wird. Das Babel-Plugin von Reanimated erkennt diese Markierung zur Build-Zeit, extrahiert den Funktionskörper und hängt eine kompilierte, auf dem UI-Thread ausführbare Repräsentation an die Funktion an.
Damit dieser Mechanismus zuverlässig greift, muss der Plugin-Eintrag in babel.config.js als letztes Element im plugins-Array stehen, weil er den Code nach allen anderen Transformationen analysieren muss. Fehlt er oder steht er an falscher Stelle, kompiliert die App zwar, wirft aber zur Laufzeit kryptische Fehler über fehlende Funktionen auf dem UI-Thread, sobald ein Worklet tatsächlich ausgeführt wird.
// babel.config.js
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
// Muss als LETZTES Element stehen, damit alle anderen
// Transformationen bereits durchgelaufen sind.
'react-native-reanimated/plugin',
],
};
3. Was beim Kompilieren eines Worklets technisch passiert
Zur Build-Zeit extrahiert das Babel-Plugin den Funktionskörper eines Worklets als Quelltext-String, ermittelt die verwendeten Closure-Variablen und hängt an die Funktion Metadaten wie einen Worklet-Hash und die serialisierten Anfangswerte der Closure an. Zur Laufzeit kompiliert die Reanimated-Runtime diesen String auf dem UI-Thread einmalig zu ausführbarem Code, meist über Hermes, das auf beiden Threads läuft und dadurch identisches Sprachverhalten garantiert.
Aus dieser Funktionsweise ergibt sich eine wichtige Einschränkung: Ein Worklet kann nicht beliebige Module importieren oder komplexe Klasseninstanzen aus dem umgebenden Scope nutzen, weil nur das serialisiert werden kann, was die JSI-Brücke abbilden kann, also im Wesentlichen primitive Werte, Arrays, Objekte und Shared Values. Der Versuch, eine große externe Bibliothek direkt in einem Worklet zu verwenden, scheitert deshalb meist schon beim Kompilieren.
4. Closures in Worklets: Werte werden kopiert, nicht referenziert
Wenn ein Worklet erzeugt wird, kopiert Reanimated die zu diesem Zeitpunkt sichtbaren Closure-Variablen in die serialisierte Repräsentation. Ein normaler React-State- oder Prop-Wert, der im Worklet gelesen wird, zeigt deshalb nur den Stand vom letzten Render, in dem das Worklet neu erzeugt wurde, und aktualisiert sich nicht automatisch, wenn sich der State danach ändert. Genau das überrascht viele Entwickler, die aus der klassischen React-Welt kommen und Closures als lebende Referenzen gewohnt sind.
Ein Shared Value verhält sich anders, weil nicht der Wert selbst, sondern das Shared-Value-Objekt mit seiner .value-Eigenschaft in die Closure kopiert wird. Da dieses Objekt auf beiden Threads dieselbe zugrunde liegende Speicherstelle referenziert, liefert der Zugriff auf .value innerhalb des Worklets immer den aktuellen Wert, unabhängig davon, wann das Worklet ursprünglich erzeugt wurde. Die Faustregel lautet deshalb: alles, was sich während der Lebensdauer eines Worklets ändern soll, gehört in einen Shared Value.
function Example({ threshold }: { threshold: number }) {
// Falsch: 'threshold' wird beim Erstellen des Worklets kopiert.
// Ändert sich die Prop später, sieht das Worklet den alten Wert.
const staleStyle = useAnimatedStyle(() => {
return { opacity: offset.value > threshold ? 1 : 0 };
});
// Richtig: Grenzwert selbst als Shared Value führen.
const thresholdSV = useSharedValue(threshold);
useEffect(() => {
thresholdSV.value = threshold;
}, [threshold]);
const liveStyle = useAnimatedStyle(() => {
return { opacity: offset.value > thresholdSV.value ? 1 : 0 };
});
return <Animated.View style={liveStyle} />;
}
5. runOnJS: sicher vom UI-Thread zurück zum JS-Thread wechseln
Sobald ein Worklet auf dem UI-Thread etwas auslösen soll, das nur auf dem JS-Thread existiert, etwa ein React-State-Update, eine Navigation oder ein Analytics-Aufruf, kommt runOnJS ins Spiel. Es plant den übergebenen Funktionsaufruf asynchron auf der Warteschlange des JS-Threads ein und serialisiert dabei die Argumente, ähnlich einer strukturierten Kopie, damit sie den Thread-Wechsel unbeschadet überstehen.
Wichtig ist, dass runOnJS keinen Rückgabewert an den aufrufenden Worklet-Code liefert und die Ausführung nicht synchron erfolgt. Wer runOnJS in jedem einzelnen Animationsframe aufruft, etwa bei jedem Pixel Scroll-Offset, flutet die JS-Thread-Warteschlange und untergräbt damit genau den Performance-Vorteil, den Reanimated eigentlich bringen soll. In der Praxis gehört runOnJS deshalb an Ereignis-Enden, nicht in laufende Animationsschleifen.
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
})
.onEnd(() => {
translateX.value = withSpring(0);
// Nur EINMAL am Gestenende auf den JS-Thread wechseln,
// nicht bei jedem onUpdate-Aufruf.
runOnJS(trackSwipeCompleted)();
});
6. runOnUI: gezielt Code auf den UI-Thread schicken
runOnUI ist die Umkehrung von runOnJS: JS-Thread-Code, etwa ein Button-Handler oder ein Effect, schickt damit gezielt einen Worklet-Aufruf auf den UI-Thread. Das ist seltener nötig als runOnJS, weil die meisten Animationen bereits automatisch über Änderungen an Shared Values ausgelöst werden, aber es ist essenziell für imperative APIs wie das Messen einer View mit measure() oder das gezielte Ausführen einer Animation außerhalb des normalen Render-Zyklus.
Ein typischer Anwendungsfall ist eine Layout-Messung nach einem Tastendruck, bei der die aktuelle Position und Größe einer View auf dem UI-Thread benötigt wird, bevor eine darauf aufbauende Animation gestartet werden kann. Da measure() selbst ein Worklet ist, muss der Aufruf entweder aus einem bestehenden Worklet-Kontext oder explizit über runOnUI erfolgen.
const animatedRef = useAnimatedRef<Animated.View>();
function measureAndAnimate() {
runOnUI(() => {
'worklet';
const layout = measure(animatedRef);
if (layout === null) {
return;
}
scale.value = withTiming(layout.width > 200 ? 1.2 : 1);
})();
}
7. Typische Fehler: JS-Werte im Worklet lesen
Der häufigste Fehler ist der Zugriff auf ein Ref-Objekt (etwa einen normalen useRef) oder einen Zustand aus useState innerhalb eines Worklets, in der Erwartung, dass sich Änderungen live widerspiegeln. Weil beides nicht über die JSI-Brücke synchronisiert wird, sieht das Worklet entweder einen eingefrorenen Anfangswert oder wirft beim Zugriff auf ein current-Feld, das im Worklet-Kontext gar nicht existiert, einen Laufzeitfehler.
Ein zweiter klassischer Fehler ist der Aufruf einer importierten Hilfsfunktion, etwa aus einer Utility-Datei oder einer Bibliothek, die selbst nicht als Worklet kompiliert wurde. Reanimated meldet dann zur Laufzeit, dass die Funktion nicht synchron auf dem UI-Thread aufgerufen werden kann. Die Lösung ist, die betroffene Hilfsfunktion ebenfalls mit der Direktive 'worklet' zu versehen oder die Logik in eine bereits als Worklet erkannte Reanimated-Utility zu verlagern.
Ein dritter, subtilerer Fehler ist die Annahme, dass console.log innerhalb eines Worklets sofort und in Reihenfolge mit JS-Thread-Logs erscheint. Tatsächlich wird die Ausgabe über einen Proxy zurück auf den JS-Thread gespiegelt und kann dadurch zeitlich versetzt oder in anderer Reihenfolge im Terminal auftauchen, was beim Debuggen zu falschen Schlussfolgerungen über die tatsächliche Ausführungsreihenfolge führen kann.
8. Worklets debuggen: Logger, Sourcemaps und Fehleranalyse
Reanimated bringt einen konfigurierbaren Logger mit, der über configureReanimatedLogger Warnstufen und das Verhalten bei typischen Fallstricken steuert, etwa dem direkten Lesen eines Shared Values während eines normalen React-Renders statt innerhalb eines Worklets. Diese Warnungen sind in der Entwicklung Gold wert, weil sie genau die Fälle abdecken, die sonst erst als schwer reproduzierbarer Bug in der App auffallen würden.
Fehler, die innerhalb eines Worklets geworfen werden, liefern in aktuellen Reanimated-Versionen einen brauchbar symbolisierten Stacktrace, der über Metro aufgelöst wird. Bei älteren Setups oder Production-Builds ohne Sourcemaps bleibt oft nur ein minifizierter Stacktrace übrig, weshalb es sich lohnt, kritische Animationslogik zunächst im Development-Build mit aktivierter Symbolisierung zu testen, bevor sie in ein Release-Build wandert.
9. Praxis-Pattern: Scroll-Handler ganz ohne Bridge-Overhead
Ein realistisches Beispiel für sauber getrennte Verantwortlichkeiten ist ein Scroll-Handler, der die Kopfzeile beim Scrollen ein- und ausblendet, dabei aber nur am Ende der Scroll-Geste ein Analytics-Event auf dem JS-Thread auslöst. Der Offset selbst bleibt vollständig auf dem UI-Thread, während runOnJS gezielt nur an der einen Stelle eingesetzt wird, an der er tatsächlich nötig ist.
Dieses Muster, möglichst viel Logik im Worklet zu belassen und den JS-Thread nur an klar definierten Übergängen einzubeziehen, ist der Kern jedes performanten Reanimated-Codes. Wer stattdessen bei jeder kleinen Änderung zwischen den Threads hin und her springt, verliert genau den Vorteil, den das gesamte Worklet-Modell überhaupt erst bringt.
const headerVisible = useSharedValue(true);
let lastOffset = 0;
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
const current = event.contentOffset.y;
headerVisible.value = current < lastOffset || current < 20;
lastOffset = current;
},
onEndDrag: () => {
runOnJS(trackScrollSettled)(lastOffset);
},
});
const headerStyle = useAnimatedStyle(() => ({
transform: [{ translateY: withTiming(headerVisible.value ? 0 : -80) }],
}));
| Aufgabe | Läuft auf | Empfohlenes API | Typischer Stolperstein |
|---|---|---|---|
| Style aus Shared Value ableiten | UI-Thread | useAnimatedStyle |
State direkt statt über .value lesen |
| State-Update nach Geste | JS-Thread | runOnJS(setState) |
runOnJS vergessen, App stürzt ab |
| Bestehende Instanzmethode aufrufen | UI-Thread | runOnUI(fn)() |
Zielfunktion ist kein Worklet |
| Scroll-Position live auswerten | UI-Thread | useAnimatedScrollHandler |
Klassischen onScroll statt Worklet nutzen |
| Analytics-Event nach Animation senden | JS-Thread | runOnJS(trackEvent) |
Netzwerk-Aufruf direkt im Worklet versuchen |
| Layout einer View messen | UI-Thread | measure(animatedRef) |
Messung im JS-Thread mit veraltetem Layout |
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
Reanimated Worklets
Kernidee
Worklets laufen kompiliert direkt auf dem UI-Thread und sind dadurch unabhängig von JS-Thread-Last.
runOnJS
Schickt einen Aufruf asynchron vom UI-Thread zurück zum JS-Thread, etwa für State-Updates.
runOnUI
Schickt einen Worklet-Aufruf gezielt vom JS-Thread auf den UI-Thread, etwa für imperative Messungen.
Größte Falle
Closures kopieren Werte bei Erstellung, State und Props ohne Shared Value bleiben im Worklet eingefroren.