Accueil / Articles

Où se cache la latence de React Native : les franchissements de frontières, et non du code lent

Découvrez pourquoi les APIs rapides et les composants mémorisés peuvent encore sembler lents dans React Native, ainsi que la manière dont le regroupement des opérations, le throttling et les concepts de conception de l’époque JSI réduisent les coûts liés aux différents environnements d’exécution.

Éditeur : REACTAPP.TOP19 min de lecture4454 mots

Sources: The Hidden Latency Cost in React Native Bridge Architectures

Ce guide explique comment s’est déroulée cette transmission sous l’ancien Bridge, pourquoi son coût s’accumule plutôt que de figurer dans une seule appel, quels changements apporte réellement la Nouvelle Architecture (JSI, TurboModules, Fabric et le rendu redessiné), ainsi quels habitudes de conception permettent de maintenir un trafic inter-runtime peu coûteux quel que soit l’architecture utilisée. La Nouvelle Architecture est devenue la configuration par défaut depuis React Native 0.76, mais il reste utile de comprendre le Bridge : de nombreuses applications en production intègrent encore des modules anciens, des couches d’interopérabilité, des bibliothèques et des modèles plus anciens qui ont été influencés par lui. Plus important encore, supprimer le Bridge ne rend pas gratuite une communication mal conçue entre les différents environnements d’exécution.

Deux environnements d’exécution dans une seule application

┌──────────────────────────────┐
│        JavaScript / TS       │
│                              │
│ React Components             │
│ State Management             │
│ Business Logic               │
│ API Handling                 │
└──────────────┬───────────────┘
               │
               │ JS ↔ Native communication
               │
┌──────────────▼───────────────┐
│          Native Side         │
│                              │
│ Android / iOS APIs           │
│ Camera                       │
│ Bluetooth                    │
│ GPS                          │
│ Storage                      │
│ Native Views                 │
│ Sensors                      │
└──────────────────────────────┘

  • l’affichage d’interfaces utilisateur natives,
  • l’ouverture de l’appareil photo,
  • la lecture des coordonnées GPS,
  • l’accès au système de fichiers,
  • la déclenchement d’effets haptiques,
  • la communication avec des appareils Bluetooth,
  • l’utilisation d’un stockage sécurisé,
  • l’appel à des SDK natifs de tiers,
  • la réception de données provenant de capteurs,
  • l’invoque d’autres API spécifiques à la plateforme.

Ainsi, les données circulent constamment entre les deux environnements. Dans l’architecture classique, tout ce trafic passait par le React Native Bridge.

Comment l’ancien Bridge transmettait une requête

La conception initiale séparait délibérément l’exécution en JavaScript de l’exécution native. Un aller-retour typique se déroulait à peu près comme ceci :

JavaScript
    ↓
Prepare parameters
    ↓
Serialize data
    ↓
Queue bridge message
    ↓
Cross execution boundary
    ↓
Native receives message
    ↓
Native performs operation
    ↓
Prepare result
    ↓
Serialize result
    ↓
Queue response
    ↓
JavaScript receives result

La caractéristique fondamentale est que tout cet échange était asynchrone. Les arguments étaient serialisés, placés dans une file d’attente, transmis de l’autre côté, déserialisés et traités du côté natif, et tout résultat effectuait le même trajet en retour. L’équipe de React Native a qualifié la serialisation et le passage asynchrone de données de limites majeures du vieux modèle.

Pour des appels occasionnels, cette surcharge est inoffensive. Écrire une chaîne de caractères dans le presse-papiers en est un bon exemple :

await Clipboard.setString("Hello");

Quel que soit le coût de ce passage, aucun utilisateur ne le percevra jamais. Les problèmes commencent lorsque la frontière devient un canal à haute fréquence sur lequel l’application s’appuie en permanence.

Une seule appelée est peu coûteuse ; c’est le schéma répétitif qui pose problème

Les discussions sur les performances de Bridge suggèrent souvent que chaque appelée est excessivement chère. Cette façon de voir manque le point essentiel. Les ralentissements réels proviennent généralement de la combinaison de plusieurs facteurs qui s’accumulent :

fréquence × taille du chargement × pression de planification × exigences de synchronisation

Supposons qu’un seul passage coûte presque rien. Maintenant, augmentons le nombre de passages :

1 call
10 calls
100 calls
1,000 calls
10,000 calls

Augmentez alors la taille de chaque charge utile, envoyez des mises à jour en continu pendant que l’utilisateur fait défiler, ou exigez un aller-retour Native → JavaScript → Native avant que l’état visuel suivant ne puisse être affiché. Un coût qui était négligeable isolément se trouve désormais sur le chemin de rendu critique. Aucune fonction individuelle ne semble lente dans un outil d’analyse ; la latence est répartie dans tout le système, c’est précisément pourquoi elle reste cachée.

Considérez la latence d’interaction comme un pipeline

Il est utile de noter chaque étape que traverse une interaction :

Total Latency =
    JS Work
  + Serialization
  + Queue Waiting
  + Context Boundary Cost
  + Native Work
  + Return Communication
  + Rendering Delay

Les équipes ont tendance à ajuster un élément isolément, généralement le travail natif :

Native Work

ou le travail JavaScript :

JS Work

Cependant, l’utilisateur n’expérimente que la somme de tous ces éléments :

TOTAL LATENCY

Considérez une opération native qui s’achève en :

2 ms

C’est déjà un chiffre excellent en soi. Mais la répartition détaillée pourrait être la suivante :

JS preparation             2 ms
Serialization              2 ms
Queue waiting              4 ms
Native operation           2 ms
Response communication     3 ms
React update               4 ms
--------------------------------
Total                     17 ms

La « opération de 2 ms » s’est transformée en une interaction d’environ 17 ms. Les documents de React Native indiquent un budget par frame d’environ 16,67 ms à 60 FPS ; si ce budget est dépassé, un frame peut être supprimé. De nombreux téléphones actuels affichent des mises à jour plus rapides que 60 Hz, ce qui réduit encore davantage ce budget.

Les budgets par frame laissent peu de marge pour les coûts supplémentaires

Au rythme de 60 Hz, les calculs sont les suivants :

1000 ms / 60 ≈ 16.67 ms

Cela donne approximativement

16.67 ms

pour générer chaque frame. À 120 Hz, ce temps est divisé par deux :

1000 ms / 120 ≈ 8.33 ms

Environ huit millisecondes, c’est très peu. Si une partie de ce temps est utilisée inutilement pour transférer des données d’un environnement à l’autre, c’est l’architecture elle-même qui consomme le budget de rendu. Les symptômes sont familiers :

  • animations saccadées,
  • gestes qui répondent avec retard,
  • transitions de navigation lentes,
  • frames manquants,
  • retard dans la réaction au toucher,
  • affichage décalé lors du scroll,
  • mises à jour de l’interface qui arrivent une frame après l’heure prévue.
  • Les documents sur les performances distinguent également le FPS du thread JavaScript du FPS du thread UI (principal). Lorsque le thread JavaScript est saturé, il ne peut pas traiter rapidement les événements de toucher ni les mises à jour de l’interface générées par JavaScript, tandis que les interactions natives, comme le scroll d’une liste native, peuvent rester fluides. Cette séparation explique un problème de débogage classique : une partie de l’écran semble fluide tandis qu’une autre semble figée.

    Scénario : diffusion de la localisation sur une carte

    Imaginez une application de covoiturage qui reçoit un flux continu d’informations GPS. La connexion correspondante ressemble à ceci :

    Native GPS
        ↓
    Bridge
        ↓
    JavaScript
        ↓
    State Update
        ↓
    React Render
        ↓
    Bridge
        ↓
    Native Map
        ↓
    Marker Position Updated
    

    Exécutez maintenant cette boucle pour chaque correction. Ni le calcul de la position ni le déplacement d’un marqueur ne sont coûteux en soi, mais la conception crée un aller-retour répétitif :

    Native → JS → Native
    

    Cela se reproduit sans cesse tant que l’écran est ouvert. Affichez de nombreux pilotes en même temps et le trafic augmente considérablement :

    Driver 1 position
    Driver 2 position
    Driver 3 position
    Driver 4 position
    ...
    Driver 100 position
    

    Émettre cent mises à jour minuscules une par une peut être bien plus coûteux que d’émettre un seul lot bien préparé décrivant toutes ces mises à jour. La cause racine réside dans la structure du flux de données, et non dans le rendu de React.

    Scénario : flux de capteurs

    Les données de l’accéléromètre et du gyroscope suivent le même schéma. Supposons que le code natif émette :

    60 events / second
    

    Chaque événement passe dans JavaScript, y est traité, et peut déclencher une nouvelle mise à jour retournant vers l’interface utilisateur native. Le résultat est un pipeline en temps réel qui traverse cette frontière :

    Sensor
      ↓
    Native
      ↓
     JS
      ↓
    Calculation
      ↓
    React
      ↓
    Native UI
    

    À un faible taux d’événements, cela peut fonctionner parfaitement bien. À un taux élevé, et surtout sur du matériel moins puissant, les événements peuvent arriver plus rapidement que l’application ne peut les traiter, ce qui introduit un coût caché supplémentaire.

    Files d’attente qui grandissent plus vite qu’elles ne sont épuisées

    Supposons que les événements arrivent tous les

    10 ms
    

    mais que le traitement efficace de chacun prend

    15 ms
    

    Ce n’est plus simplement une opération lente ; il s’agit d’un arriéré qui grandit sans limite. De nouvelles lectures arrivent tandis que les anciennes attendent encore d’être traitées, de sorte que l’interface affiche toujours des données du passé. Pour les interfaces en temps réel, c’est le pire scénario possible. Un utilisateur a rarement besoin de chaque échantillon intermédiaire ; ce dont il a besoin, c’est de la dernière valeur pertinente. Les conceptions qui regroupent ou suppriment les événements obsolètes maintiennent la file d’attente courte et l’affichage à jour.

    Les interfaces conversationnelles multiplient les traversées

    Cette leçon n’est pas spécifique à React Native ; les ingénieurs qui conçoivent des systèmes distribués y sont confrontés quotidiennement. Comparez une API de module qui définit un champ par appel :

    await nativeModule.setName(name);
    await nativeModule.setEmail(email);
    await nativeModule.setAge(age);
    await nativeModule.setCountry(country);
    await nativeModule.setLanguage(language);
    

    à une autre qui accepte toute la mise à jour d’un seul coup :

    await nativeModule.updateProfile({
      name,
      email,
      age,
      country,
      language,
    });
    

    La deuxième version nécessite bien moins de traversées pour obtenir le même résultat. La règle de base est simple :

    Faites plus de travail à chaque traversée, et traversez moins souvent.

    Le même principe s’applique à :

    • les API HTTP,
    • les appels de microservices,
    • l’accès aux bases de données,
    • les threads d’exécution,
    • la communication inter-processus,
    • les ponts mobiles,
    • les systèmes distribués en général.

    Dans les réseaux, on appelle cela une communication bavarde. Le pont classique n’a fait qu’introduire ce problème typique des systèmes distribués au sein d’une seule application mobile.

    La taille du chargement est la deuxième dimension

    La fréquence des échanges n’est qu’une partie de l’histoire ; la quantité de données transmises est également importante. Prenons l’exemple de ce que l’on pourrait envoyer à travers cette frontière classique :

    {
      users: [...10000Users]
    }
    

    Le expéditeur doit encoder la structure, et le destinataire doit la reconstruire et la traiter. De grands tableaux, des objets profondément imbriqués, les métadonnées d’images, les données binaires et de grands arbres de configuration ajoutent tous une charge supplémentaire mesurable. Les tâches les plus exposées à ce problème sont :

    • les cadres vidéo,
    • les buffers audio,
    • le traitement d’images,
    • les données d’entrée pour l’apprentissage automatique,
    • les grands résultats de requête,
    • les animations complexes,
    • les cartes en direct,
    • les flux de capteurs.

    La documentation sur l’architecture React Native utilise un cas extrême mais instructif : les buffers d’images de caméra en temps réel, qui peuvent représenter des dizaines de mégaoctets par image et d’énormes volumes par seconde. Serialiser à répétition ce type de données via un pont asynchrone est tout simplement l’outil inadapté.

    Pourquoi les pipelines de caméra rendent le coût évident

    Une seule image peut contenir des mégaoctets de pixels. Maintenant, transmettre ces images à

    30 FPS
    

    ou même

    60 FPS
    

    Si chacune d’elles doit être convertie en une forme serialisable compatible avec JavaScript et envoyée via une couche asynchrone, le transport devient le goulot d’étranglement. Il convient d’éviter cette situation pour chaque image :

        Native Camera
             ↓
    Huge serialized payload
             ↓
        JavaScript
             ↓
          Process
             ↓
       Serialized result
             ↓
          Native
    

    C’est pourquoi les bibliothèques à haute performance conservent de plus en plus le traitement intensif près de la mémoire native et fournissent à JavaScript une interface étroite et efficace en contrepartie. Cette orientation mène directement à l’un des changements les plus importants dans React Native moderne.

    Quels sont les changements apportés par JSI

    JSI (JavaScript Interface) permet à JavaScript d’interagir de manière beaucoup plus directe avec du code natif et en C++. Au lieu de dépendre entièrement de la méthode de sérialisation et d’enfilement utilisée par l’ancien Bridge :

    JavaScript
         ↓
     Serialize
         ↓
       Bridge
         ↓
    Deserialize
         ↓
      Native
    

    la nouvelle architecture peut exposer des fonctionnalités natives via des interfaces directes :

    JavaScript
         ↓
        JSI
         ↓
    C++ / Native
    

    D’après la documentation de React Native, JSI permet au JavaScript de conserver des références vers des objets C++ et au C++ de conserver des références vers des objets JavaScript, ce qui permet d’appeler des méthodes sans recourir au modèle de sérialisation du Bridge. Conceptuellement, l’appel d’une méthode native ressemble davantage à l’appel d’une fonction sur un objet hôte qu’à l’envoi d’un message vers un autre processus. Il s’agit d’un changement fondamental dans l’architecture.

    Pourquoi JSI ne résout pas tous les problèmes de performance

    Une interprétation erronée courante est la suivante :

    "JSI élimine le Bridge, donc les problèmes de performance disparaissent."

    C’est faux. Le mécanisme de transmission est bien meilleur, mais une mauvaise conception du système génère toujours des retards. Cent mille appels synchrones via JSI :

    100,000 synchronous native calls
    

    Chacune de ces méthodes peut être moins coûteuse qu’une appel via Bridge, mais la majeure partie du travail reste inutile. Il en va de même pour le transfert répété d’objets volumineux, l’exécution de logiques gourmandes en ressources CPU sur le thread JavaScript, le blocage du thread principal de l’interface utilisateur, ou encore le rendu de milliers de composants qui n’avaient pas besoin d’être affichés. L’accès synchrone introduit même un nouveau risque : un appel natif synchrone lent bloque désormais directement le thread JavaScript au lieu d’être traité ultérieurement.

    Ainsi, la leçon à retenir n’est pas « Bridge était lent, JSI est rapide ». Cela se rapproche plutôt de ceci :

    Tout passage entre environnements d’exécution a un coût. Structurez l’application de manière à ce que des passages inutiles n’aient jamais lieu.

    TurboModules : le système moderne de modules natifs

    TurboModules remplace l’ancien système de modules natifs. Les anciens modules étaient accessibles via le Bridge ; les nouveaux s’intègrent plus étroitement grâce à JSI et Codegen, qui génère des bindings typés à partir d’une spécification. Parmi les avantages énumérés par React Native, on trouve :

    • un accès natif synchrone lorsque c’est requis par un cas d’usage,
    • une sécurité de type à travers la frontière entre les technologies,
    • l’interopérabilité avec C++,
    • le partage de code entre différentes plateformes,
    • le chargement différé des modules.

    Le chargement différé mérite une attention particulière. Dans l’ancien modèle, l’initialisation des modules natifs au démarrage pouvait entraîner des opérations supplémentaires avant même l’apparition de la première interface. Avec le chargement différé, un module n’est initialisé que lorsqu’il est utilisé pour la première fois. Au lieu de :

    Application starts
            ↓
    Initialize everything
            ↓
    Eventually show UI
    

    le démarrage peut ressembler davantage à ceci :

        Application starts
                  ↓
    Initialize required components
                  ↓
               Show UI
                  ↓
    Load additional modules when needed
    

    De telles modifications ont un impact bien plus perceptible pour les utilisateurs que les chiffres de benchmark : la vitesse à laquelle l’application semble prête à être utilisée.

    Fabric : l’aspect rendu du redessin

    La couche de communication n’était pas la seule partie à avoir été reconstruite. Fabric, le nouveau système de rendu, déplace une partie plus importante de l’infrastructure de rendu vers du C++ partagé et permet des fonctionnalités qui étaient difficiles à mettre en œuvre avec l’ancien modèle asynchrone, notamment :

    • un accès synchrone au layout là où c’est nécessaire,
    • une meilleure interopérabilité,
    • plusieurs priorités d’actualisation,
    • un soutien aux fonctionnalités modernes de React,
    • un planification plus flexible.

    Les priorités sont importantes car les interfaces ne traitent pas chaque mise à jour de la même manière :

    User presses button        → HIGH priority
    Background data refresh    → LOWER priority
    Analytics event            → LOW priority
    Animation                  → HIGH priority
    Prefetching                → LOW priority
    

    la manière dont React utilise des masques binaires pour encoder la priorité explique en détail ce mécanisme.

    le changement de contexte : le transfert répétitif de petits fragments d’informations d’un environnement d’exécution à un autre.

      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
    

    Lorsque vous voyez cette structure, cela signifie généralement que la responsabilité d’une fonctionnalité a été divisée au mauvais endroit. La question à se poser est de savoir où ce travail devrait réellement être placé. Prenons le traitement audio : une architecture sujette à des problèmes de performance ressemble à ceci :

    Native microphone
    → JS audio frame
    → calculate
    → native
    → next frame
    → JS
    → calculate
    → native
    

    Une meilleure organisation conserve le traitement des hautes fréquences en mode natif et n’envoie au JavaScript que les résultats qui méritent d’être traités :

    Native microphone
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Meaningful result
            ↓
        JavaScript
    

    Le volume de calcul peut être similaire, mais le modèle de communication est complètement différent, tout comme la charge sur les deux threads.

    Techniques pratiques pour des échanges moins coûteux

    Grouper les opérations en une seule appel

    Une boucle qui attend un appel natif pour chaque élément est une source fréquente de problèmes de performance :

    for (const item of items) {
      await NativeModule.saveItem(item);
    }
    

    Avec 500 éléments, cela représente potentiellement des centaines de traversées distinctes, chacune entraînant ses propres coûts d’ordonnancement. Une API native plus performante accepte l’ensemble de la collection et permet au code natif d’effectuer l’itération :

    await NativeModule.saveItems(items);
    

    La différence de forme est facile à décrire :

    ❌ 500 boundary crossings × small operation
    
    ✅ 1 boundary crossing × 500 native operations
    

    Le regroupement fait partie des optimisations architecturales les plus simples et les plus efficaces disponibles. Un détail de conception à prendre en compte : une requête par lot nécessite un contrat d’erreur clair, permettant de signaler quels éléments ont échoué, afin qu’un seul élément défectueux ne rejette pas silencieusement le reste.

    Réguler ou fusionner les événements à haute fréquence

    Supposons qu’un module natif émette 120 mises à jour par seconde. JavaScript n’a que rarement besoin de toutes. Pour de nombreuses interfaces, environ 20 mises à jour significatives par seconde donnent un résultat pratiquement identique à l’écran. Au lieu de gérer

    120 events / second
    

    vous pourriez traiter

    20 events / second
    

    ou accumuler les événements du côté natif et ne transmettre que l’état le plus récent. Effectuer la limitation du rythme de manière native est généralement préférable à le faire en JavaScript, car les événements échoués en JavaScript ont déjà payé le coût de transmission. Cette approche convient à :

    • le suivi de localisation,
    • les événements de défilement,
    • les données des capteurs,
    • la transmission des progrès,
    • les niveaux audio,
    • les statistiques réseau,
    • le déplacement sur la carte,
    • les tableaux de bord en direct.

    L’événement le moins coûteux est celui qui n’est jamais envoyé.

    Transférer le sens, pas les données brutes

    Au lieu d’envoyer quelque chose comme

    10 MB raw dataset
    

    du côté natif vers JavaScript et de demander à JavaScript d’en déduire une réponse, calculez la réponse là où les données se trouvent déjà. Remplacez ce flux :

    Native
      ↓
    10 MB data
      ↓
    JavaScript
      ↓
    find average
    

    par celui-ci :

            Native
              ↓
      calculate average
              ↓
    JavaScript receives: 42.7
    

    En bref, transférez

    meaning
    

    au lieu de

    raw information
    

    chaque fois que l’architecture le permet. Le compromis est que la logique passe dans du code natif, qui doit être écrit et testé pour chaque plateforme ou en C++ partagé ; réservez cela aux données qui sont véritablement volumineuses ou fréquentes.

    La latence réseau n’est pas la latence d’interaction

    120 ms
    

    et le tableau de bord de surveillance affiche une couleur verte. Cependant, après l’arrivée de la réponse, beaucoup d’étapes restent à effectuer :

    API Response
        ↓
    JSON Parsing
        ↓
    State Transformation
        ↓
    Redux / Zustand / Context
        ↓
    Component Re-rendering
        ↓
    JS ↔ Native interaction
        ↓
    Layout
        ↓
    UI Commit
    

    D’ici que le résultat apparaisse à l’écran, l’utilisateur a pu attendre

    200+ ms
    

    Les rapports de surveillance du backend indiquent que tout fonctionne rapidement ; l’utilisateur, quant à lui, affirme que l’application est lente. Les deux ont raison, car ils mesurent des choses différentes. La latence du backend et la latence d’interaction sont des métriques distinctes, et pour les applications mobiles, c’est généralement cette dernière qui détermine si l’expérience utilisateur est satisfaisante.

    Tester sur les appareils que possèdent réellement vos utilisateurs

    Les problèmes de performance aiment se cacher sur le matériel des développeurs. Un téléphone haut de gamme dispose d’un CPU rapide, de beaucoup de mémoire, d’un stockage rapide, d’une GPU puissante et d’une bonne capacité de refroidissement, ce qui fait que tout semble fonctionner correctement. En revanche, les utilisateurs finaux exécutent la même version sur des téléphones disposant de bien moins de ressources, et

    small inefficiency × thousands of operations
    

    les problèmes deviennent soudainement visibles. Tester uniquement sur des appareils haut de gamme est risqué. Une matrice d’appareils fiable doit couvrir au moins :

    High-end device
    Mid-range device
    Lower-end supported device
    

    Cela est particulièrement important lorsque votre public inclut des marchés où les téléphones Android abordables représentent une grande partie des utilisateurs.

    Mesurez les versions de déploiement, pas les versions de développement

    Les versions de développement effectuent des tâches supplémentaires pour les avertissements, le débogage et les outils, et le guide officiel sur les performances recommande explicitement de mesurer avec des versions de déploiement. Avant de conclure que React Native est lent en soi, effectuez un profilage d’une

    Release / Production build
    

    plutôt que de vous fier uniquement à une

    Development build
    

    Sinon, vous pouvez passer des heures à corriger un ralentissement que aucun utilisateur ne rencontrera jamais.

    Liste de contrôle diagnostique pour un écran lent

    L’utilisation dispersée de useMemo() sur un écran lent n’est rarement la bonne première démarche. Répondez plutôt à ces questions.

    1. Le thread JavaScript est-il occupé ?

    Recherchez :

    • des boucles coûteuses,
  • analyse synchrone de grandes quantités de données,
  • transformations d’état complexes,
  • re-renderings inutiles,
  • journalisation excessive,
  • arbres de composants très volumineux.
  • 2. Le thread UI est-il occupé ?

    Recherchez :

    • rendu natif coûteux,
    • maquettes complexes,
    • images de grande taille,
    • hiérarchies de vues profondes,
    • animations exécutées sur le mauvais thread.

    3. La frontière native est-elle franchie trop souvent ?

    Recherchez des allers-retours de ce type, en particulier ceux à haute fréquence :

    Native → JS → Native
    

    4. Quelle est la taille des données transmises ?

    Compter les appels ne suffit pas. Mesurez la quantité de données qui circule à travers chacun d’eux.

    5. Les appels peuvent-ils être regroupés ?

    Lorsque c’est possible, transformez

    many small operations
    

    en

    one meaningful operation
    

    6. Le JavaScript a-t-il vraiment besoin de ces données ?

    Si le code natif génère 1 000 points de données et que JavaScript n’utilise que leur moyenne, envoyez cette moyenne.

    7. Est-ce nécessaire de le faire immédiatement ?

    Conserver

    User-critical work
    

    à part

    background work
    

    Les outils d’analyse ne doivent jamais concurrencer les réactions tactiles pour la même frame.

    8. Y a-t-il encore une dépendance vis-à-vis de l’architecture legacy ?

    Les documents actuels de React Native qualifient les Modules natifs legacy et les Composants natifs legacy de dépréciés, et recommandent les Modules natifs Turbo Native ainsi que les Composants natifs Fabric lorsque c’est approprié. Lorsque vous choisissez un package pour une fonctionnalité sensible à la performance, vérifiez quelle architecture il prend en charge ; une bibliothèque fonctionnant via la couche d’interopérabilité ne garantit pas nécessairement les améliorations escomptées. L’état de support évolue rapidement, il convient donc de le vérifier dans les notes de version actuelles de la bibliothèque.

    Règle pour l’affectation des tâches

    Pour toute fonctionnalité sensible aux performances, commencez par déterminer quel côté de l’application est réellement responsable de l’exécution.

    • Lorsque la tâche relève de JavaScript (règles métier, état de l’interface utilisateur, orchestration), laissez-la s’y exécuter et évitez d’avoir recours au code natif à chaque étape.
    • Lorsque la tâche relève du code natif (boucles de capteurs, traitement des images, buffers audio), laissez ces chemins gourmands en ressources s’exécuter nativement et rester sur ce niveau.
    • Croisez les frontières uniquement lorsque cela apporte une valeur ajoutée, et non pour que chaque étape intermédiaire soit visible des deux côtés.

    Une répartition équilibrée ressemble généralement à ceci :

    ┌───────────────────────┐
    │      JavaScript       │
    │                       │
    │ Business decisions    │
    │ Application state     │
    │ UI orchestration      │
    └───────────┬───────────┘
                │
         Meaningful calls
                │
    ┌───────────▼───────────┐
    │        Native         │
    │                       │
    │ Platform APIs         │
    │ High-frequency work   │
    │ Hardware interaction  │
    └───────────────────────┘
    

    et non à cela, des centaines de fois par seconde :

    JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
    

    Ce que la nouvelle architecture corrige réellement

    La nouvelle architecture élimine plusieurs limites structurelles du modèle ancien. Au lieu de construire tout autour d’un pont obligatoire, sérialisé et asynchrone, React Native moderne permet une interaction directe via JSI :

           JSI
            ↓
    Direct JS/native interfacing
    

    ainsi que les autres éléments qui s’y appuient :

           TurboModules
                ↓
    Modern native module system         Fabric
                ↓
    Modern rendering architecture         Codegen
                ↓
    Typed interfaces between environments   Modern scheduling
                ↓
    Better prioritization of work
    

    L’équipe de React Native indique que l’élimination du pont réduit la charge liée à la sérialisation, améliore le temps de démarrage et permet une communication plus directe entre JavaScript et l’environnement natif. Le changement le plus important est la flexibilité : le framework dispose désormais d’un contrôle accru sur :

    • le moment où les tâches s’exécutent,
    • l’endroit où elles s’exécutent,
    • la manière d’accéder aux fonctionnalités natives,
    • les mises à jour qui ont la priorité.

    Cette leçon s’applique à chaque frontière

    En prenant du recul par rapport à React Native, on constate que le schéma est général : chaque limite architecturale a un coût. Pensez à ces points de franchissement :

    Browser → API
    Service → Service
    Application → Database
    Process → Worker
    JavaScript → Native
    Application → Redis
    Backend → Message Broker
    

    Les systèmes sont souvent conçus comme si les appels à travers ces limites se comportaient comme des appels de fonctions ordinaires au sein du même processus. Ce n’est pas le cas. Chaque limite ajoute une combinaison de :

    • la sérialisation,
    • la copie,
    • le planification,
    • la mise en file d’attente,
    • les réseaux,
    • la synchronisation,
    • le changement de contexte,
    • la gestion des erreurs,
    • la gestion de la mémoire.

    Bridge rend ce principe particulièrement visible à l’intérieur d’une application mobile.

    Réglez l’architecture avant les micro-optimisations

    Il est tentant de recourir d’abord à des outils comme ceux-ci :

    useMemo(...)
    useCallback(...)
    React.memo(...)
    

    Ils ont des utilisations réelles. Mais si l’application transfère 5 MB de données inutiles entre les exécutions 30 fois par seconde, gagner 0,2 ms grâce à useMemo() n’aura pas d’impact significatif. Les améliorations de performance sont les plus efficaces lorsqu’elles sont réalisées dans cet ordre :

    Architecture
        ↓
    Data flow
        ↓
    Scheduling
        ↓
    Rendering
        ↓
    Algorithms
        ↓
    Micro-optimizations
    

    et non l’inverse.

    Points clés

    Le Bridge d’origine n’était pas simplement le résultat d’une mauvaise conception technique. Il permettait à JavaScript et au code natif de rester déconnectés tout en communiquant de manière asynchrone, et il a grandement contribué au développement de React Native. À mesure que les applications intégraient des fonctionnalités telles que la messagerie en temps réel, des animations complexes, des pipelines de traitement d’images et d’apprentissage automatique, des cartes, du traitement audio, des gestes complexes ainsi que d’autres interactions natives à haute fréquence, les limites de la communication asynchrone sérialisée sont rapidement devenues un obstacle. JSI, TurboModules, Fabric ainsi que le modèle de planification et de rendu révisé éliminent bon nombre de ces limites, mais pas les contraintes économiques sous-jacentes.

    • Évaluer l’ensemble du pipeline d’interaction, et non seulement l’appel natif ou la réponse API.
    • Prévoir un temps par frame : environ 16,67 ms à 60 Hz et environ 8,33 ms à 120 Hz.
    • Grouper les opérations, réguler ou fusionner les flux d’événements à la source, et envoyer des résultats plutôt que des données brutes.
  • Décidez quel moteur d’exécution assume chaque responsabilité et rassemblez les boucles à haute fréquence d’un côté.
  • Profilez les versions publiées sur des appareils de milieu et bas de gamme, et vérifiez si les dépendances font encore appel à des modules obsolètes.
  • Considérez JSI comme un moyen de transmission plus rapide, et non comme une autorisation à effectuer davantage d’appels.
  • Lorsqu’une application semble lente bien que chaque composant affiche des performances rapides, examinez les écarts entre ces composants. Souvent, la partie coûteuse n’est pas le travail lui-même, mais la distance que les données doivent parcourir pour l’atteindre.