Accueil / Articles

Le planificateur de React et le cycle d’événements : qui décide vraiment quand les tâches s’exécutent ?

Voyez comment le planificateur coopératif de React s’exécute à l’intérieur du boucle d’événements JavaScript, pourquoi les transitions peuvent être différées, et pourquoi aucun planificateur ne peut sauver un thread bloqué.

Éditeur : REACTAPP.TOP13 min de lecture3017 mots

Sources: How Does React’s Scheduler Work With the JavaScript Event Loop?

React décide non seulement de ce qui doit changer à l’écran, mais aussi du moment où cette opération a lieu, en utilisant des priorités, des canaux de traitement et un rendu interruptible. Pourtant, React n’est rien d’autre que JavaScript, qui dispose déjà d’un boucle d’événements chargée de déterminer quand le code doit s’exécuter. Alors pourquoi React intègre-t-il son propre planificateur, et lequel des deux est réellement en charge ? Cet article éclaire cette relation, montre où s’inscrivent des fonctionnalités concurrentes comme les transitions et useDeferredValue, et explique la limite intrinsèque que aucune stratégie de planification ingénieuse ne peut surmonter, tant sur le web que dans React Native.

Commencez par la pile d’appels, pas par React

JavaScript pur exécute le code sur une pile d’appels, un frame à la fois. Dans l’extrait ci-dessous, rien n’est asynchrone : un enregistrement, une appel de fonction qui enregistre également, et un dernier enregistrement.

console.log("A");

function calculate() {
  console.log("B");
}
calculate();
console.log("C");

La sortie suit exactement l’ordre du code :

A
↓
B
↓
C

Remplacez maintenant l’appel de fonction par un chronomètre avec un délai nul :

console.log("A");
setTimeout(() => {
  console.log("B");
}, 0);
console.log("C");

L’ordre change :

A
C
B

Cela surprend les gens car le délai est littéralement :

0

La explication est que setTimeout() ne signifie jamais « exécutez ceci maintenant ». Il signifie que la fonction de rappel devient éligible à l’exécution après le délai, et elle doit encore attendre que le code en cours d’exécution se termine avant que le système ne s’y attaque. Déterminer quand cela se produit relève de la responsabilité du boucle d’événements.

Un aperçu de la boucle d’événements

Une représentation simplifiée suffit pour comprendre React :

              ┌─────────────┐
              │ Call Stack  │
              └──────┬──────┘
                     │
                     ↓
              JavaScript Runs
                     │
                     ↓
              Stack becomes free
                     │
                     ↓
                Event Loop
                     │
              ┌──────┴──────┐
              ↓             ↓
         Microtasks       Tasks

Le code s’exécute sur la pile jusqu’à ce qu’elle soit vide. Ce n’est qu’alors que le boucle d’événements traite les tâches en file d’attente : les microtâches (comme les callbacks de promesses résolues) sont traitées en premier, puis la tâche suivante (comme un callback de minuterie ou un événement d’entrée). Les navigateurs et environnements de exécution réels ajoutent plus de détails, y compris les étapes de rendu entre les tâches, mais une règle prime sur toutes les autres ici : le JavaScript synchrone déjà en cours d’exécution ne peut pas être interrompu par un autre JavaScript. Si le thread est occupé, tout le reste attend. Consultez les closures et la boucle d’événements pour en savoir plus sur les files d’attente.

Une application délibérément figée

Pour comprendre ce que cette règle signifie pour React, imaginons un composant dont le gestionnaire de clic exécute une boucle intensive pendant environ cinq secondes avant de mettre à jour l’état. L’exemple utilise Button de React Native, mais le comportement est identique dans React DOM :

function App() {
  const [count, setCount] = useState(0);

function handlePress() {
    const start = Date.now();
    while (Date.now() - start < 5000) {
      // intentionally blocking JavaScript
    }
    setCount(c => c + 1);
  }
  return (
    <Button
      title={`Count: ${count}`}
      onPress={handlePress}
    />
  );
}

Pendant ces cinq secondes, le thread JavaScript :

JavaScript Thread

a l’aspect suivant :

██████████████████████████████       BLOCKED FOR 5 SEC

Les mises à jour en file d’attente, une priorité urgente, un arbre Fiber prêt à être traité : rien de tout cela n’a d’importance, car React lui-même est écrit en JavaScript et a besoin de son tour sur le thread. Le planificateur ne peut pas s’emparer de la CPU en dehors du runtime.

Qui a le contrôle

Il est tentant d’imaginer React en haut de la chaîne, dirigeant JavaScript :

React Scheduler
      ↓
Controls JavaScript

En réalité, c’est l’inverse. Le runtime accorde du temps d’exécution, et le planificateur de React décide comment l’utiliser :

JavaScript Runtime
      ↓
Provides execution opportunity
      ↓
React Scheduler
      ↓
Coordinates React work

Le planificateur fonctionne entièrement à l’intérieur de l’environnement fourni par JavaScript. Il peut organiser intelligemment le travail propre à React, mais il n’intervient que lorsqu’on lui a attribué une thread.

Alors pourquoi avoir un planificateur ?

Si React ne peut pas surcharger le cycle d’événements, en quoi son planificateur est-il utile ? Prenons l’exemple d’une interface affichant des milliers d’éléments :

App
├── Header
├── Search
├── Filters
├── ProductList
│   ├── Product
│   ├── Product
│   ├── Product
│   ├── ...
│   └── Product × 5000
└── Footer

Dans un modèle purement synchrone, l’affichage de cette structure se fait en une seule opération continue et sans interruption :

Start Render
     ↓
████████████████████████████
     ↓
Finish Entire Render

Pendant ce temps, l’utilisateur continue d’interagir :

Type
Tap
Scroll

Si l’affichage constitue une seule tâche gigantesque, ces interactions restent en file d’attente jusqu’à ce que cette tâche soit terminée, ce qui donne l’impression que l’interface est bloquée. Ce dont React a besoin, c’est de la capacité de diviser l’affichage en unités et de collaborer avec le système hôte, en lui rendant régulièrement la thread afin que les entrées de l’utilisateur et le redessin puissent avoir lieu.

Planification coopérative

Le planificateur interne de React est précisément ce type de système coopératif. Contrairement à l’ancienne méthode :

Render everything NOW

il pose continuellement une série de questions concernant le travail en attente :

There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?

En termes de flux, la boucle se présente ainsi :

Pending React Work
        ↓
     Scheduler
        ↓
Check Priority
        ↓
Perform Some Work
        ↓
Should We Yield?
   ↙             ↘
 YES             NO
  ↓               ↓
Give Host       Continue
a Chance        Rendering

React effectue une partie du travail, vérifie s’il doit céder la parole, et le cas échéant restitue le contrôle à l’environnement hôte afin que le navigateur (ou le runtime natif) puisse traiter les entrées, exécuter d’autres tâches et afficher le contenu. Ensuite, il reprend là où il s’était arrêté ou réexamine ce sur quoi travailler ensuite.

Le mot clé est coopératif. React ne retire rien à JavaScript ; il organise son propre travail de manière à ne pas retenir le thread plus longtemps que nécessaire. Cependant, la coopération ne fonctionne que si tous les participants collaborent.

Deux niveaux, deux questions

Il est utile de formuler la différence en deux questions distinctes.

  • Le boucle d’événements se demande : quand JavaScript peut-il exécuter les tâches en file d’attente ?
  • Le planificateur de React se demande : maintenant que React dispose d’un thread, quelle tâche doit être traitée en premier ?

Lorsqu’elles sont superposées, les différentes couches se présentent comme ceci :

Browser / Native Runtime
          ↓
    JavaScript Runtime
          ↓
       Event Loop
          ↓
 JavaScript gets to run
          ↓
   React Scheduler
          ↓
 Choose React Work
          ↓
       Fiber
          ↓
   Render / Reconcile
          ↓
       Commit

Chaque couche fonctionne à l’intérieur de celle qui se trouve au-dessus d’elle.

Lorsque la saisie concurrence un rendu volumineux

Imaginez React en train de rendre une longue liste de produits :

Rendering Products...
██████████████░░░░░░░░

Puis l’utilisateur commence à saisir une requête de recherche, un caractère à la fois :

R
Re
Rea
React

React dispose désormais de deux types de tâches qui se disputent le même thread :

Large List Rendering
        +
User Input Update

L’utilisateur accorde beaucoup plus d’importance à la saisie : chaque caractère doit apparaître instantanément, même si la liste met un peu de temps à se mettre à jour. Prioriser les saisies et interrompre le rendu de la liste permet d’y parvenir.

Comment Fiber fournit les points de rupture

Le planification n’est utile que si le travail peut réellement être divisé. C’est là le rôle de Fiber : chaque fibre représente le travail relatif à une partie de l’arbre, ce qui permet à la rendu d’être une séquence de petites étapes plutôt qu’une opération monolithique :

App Fiber
   ↓
Header Fiber
   ↓
Search Fiber
   ↓
List Fiber
   ↓
Product Fiber
   ↓
Product Fiber
   ↓
...

Avec le rendu concurrent, React peut traiter certaines de ces étapes, s’arrêter pour gérer quelque chose de plus urgent, puis reprendre ou recommencer :

Start Rendering
      ↓
Perform Some Fiber Work
      ↓
Pause
      ↓
Handle More Urgent Work
      ↓
Continue / Restart

La documentation de React décrit le rendu concurrent comme interruptible précisément en ce sens : un rendu peut être lancé, mis en pause, repris plus tard ou abandonné s’il est devenu obsolète, tout cela avant que quoi que ce soit ne soit enregistré. Cela diffère considérablement de l’affirmation selon laquelle React exécute du JavaScript en parallèle, ce qui n’est pas le cas.

Concurrentiel ne signifie pas parallèle

L’expression :

Concurrent React

Cela évoque souvent l’image de threads dédiés pour chaque tâche :

Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering

Celui-ci est incorrect. La concurrence dans React consiste en un entrelacement sur un seul thread, plus proche de ceci :

Work A
██████░░░░░░        ↓ pauseWork B
████        ↓Work A
██████

Le travail A s’exécute un moment, puis s’arrête ; le travail B s’exécute ensuite, avant que le travail A ne reprend. Les différentes parties du rendu sont coordonnées dans le temps, mais le code de votre composant n’est jamais exécuté sur plusieurs threads en même temps.

Tâche urgente arrivant pendant une transition

Supposons que React rende une mise à jour coûteuse qui a été encadrée dans une transition :

startTransition(() => {
  setResults(expensiveResults);
});

Pendant que ce rendu est encore en cours, l’utilisateur tape un autre caractère. La séquence ressemble alors approximativement à ceci :

Transition Render
██████████░░░░░░░
          ↓      User Types          ↓Urgent Update
████████          ↓React can reconsider
lower-priority work

Puisque la transition n’est pas urgente, React peut la mettre de côté, gérer d’abord l’entrée saisie, puis revenir à la tâche de moindre priorité, en la relançant souvent avec un état frais.

Report d’une valeur au lieu d’une mise à jour

useDeferredValue applique la même logique que du côté de la lecture. Au lieu de marquer une mise à jour d’état comme non urgente, on crée une copie différée de la valeur :

const deferredQuery = useDeferredValue(query);

La saisie continue d’utiliser l’état réel et mis à jour :

<TextInput
  value={query}
  onChangeText={setQuery}
/>

alors que le composant gourmand reçoit la copie différée :

<ExpensiveList query={deferredQuery} />

En conséquence, le champ peut déjà afficher :

React

pendant que la liste reflète encore brièvement la requête précédente :

Reac

Pendant ce temps, React prépare la nouvelle liste en arrière-plan, et chaque nouvelle frappe de touche fait simplement en sorte que cette tentative soit abandonnée pour reprendre avec la dernière requête. Le composant coûteux n’est pas rendu plus rapide pour autant ; l’objectif est purement de s’assurer que le rendu moins important ne bloque pas l’interaction sur laquelle l’utilisateur est concentré. Choisir entre ces deux outils est généralement simple : utilisez startTransition lorsque vous contrôlez la mise à jour de l’état, et useDeferredValue lorsque vous ne recevez la valeur que par exemple en tant que prop.

La limite : le planificateur ne peut pas interrompre votre code

Tout ce qui a été mentionné dépend du fait que React récupère régulièrement le thread. Considérez maintenant une fonction qui effectue un calcul synchrone très volumineux :

function calculateEverything() {
  for (let i = 0; i < 10_000_000_000; i++) {
    // huge synchronous calculation
  }
}

Pendant son exécution, le thread JavaScript :

JS THREAD

est complètement occupé :

████████████████████████████Scheduler waiting...React waiting...Input waiting...

Le planificateur, React et les entrées en attente attendent tous ensemble. Le planification coopérative ne peut interrompre son exécution que entre des unités de travail propres à React ; elle ne peut pas s’interrompre au milieu d’une boucle que vous avez écrite. Par conséquent, le rendu concurrentiel ne remplace pas un JavaScript efficace. Les calculs lourds doivent toujours être simplifiés, divisés en parties, mémorisés ou déplacés hors du thread principal, par exemple dans un worker.

Pourquoi le rendu et l’application restent séparés

C’est ce même modèle qui explique pourquoi React insiste pour diviser une mise à jour en deux phases :

Render

et :

Commit

Le rendu est le moment où React détermine à quoi devrait ressembler l’interface utilisateur, et en mode concurrentiel cette préparation peut être interrompue. Ce que React refuse de faire, c’est afficher un résultat inachevé. Ainsi, pendant la phase de rendu :

Render

React peut terminer certaines fibres, être interrompu, puis reprendre ou redémarrer ; ce n’est que lorsque tout l’arbre est prêt qu’il effectue le commit :

Fiber A ✓
Fiber B ✓
Fiber C ...
      ↓
Interrupted
      ↓
Continue / Restart
      ↓
Entire Tree Ready
      ↓
COMMIT
      ↓
Visible UI

C’est pourquoi une rendu interrompu ne produit jamais un écran mélangeant différents états :

Half old UI
+
Half new UI

Le commit n’applique jamais qu’un résultat cohérent et complet.

Pourquoi le rendu doit être pur

« Gardez les composants purs pendant le rendu » est un conseil bien connu, et le rendu interrompable montre précisément pourquoi c’est important. Examinons un composant qui lance une appel d’analyse depuis son corps :

function Product() {
  analytics.track("Product Rendered");
return <View />;
}

Sous un rendu concurrent, React peut suivre une séquence comme celle-ci :

Starts Render
    ↓
Pauses
    ↓
Abandons Work
    ↓
Renders Again

Chacune de ces tentatives exécute la fonction du composant, donc cet appel :

analytics.track(...)

Il peut être déclenché pour des rendus qui n’ont jamais été enregistrés et que personne n’a jamais vus. Vos métriques compteront alors des visualisations de produits fantômes. React a besoin de la liberté de :

Render
Pause
Retry
Abandon
Restart

sans que aucune de ces tentatives ne modifie le monde extérieur. Les effets secondaires doivent être placés dans des fonctions d’effet ou des gestionnaires d’événements, qui ne s’exécutent que pour du travail enregistré. Pour un guide pratique sur le choix de l’endroit où la logique doit être placée, consultez choisir l’outil React approprié : derive, handle, fetch, defer ou effect.

L’angle React Native

Dans une application React Native, le parcours d’une frappe au écran passe par les mêmes couches, avec un rendeur natif en fin de chaîne :

User Types
    ↓
JS Event Handler
    ↓
setState()
    ↓
React Scheduler
    ↓
Fiber Work
    ↓
React Native Renderer
    ↓
Native UI Update

Bloquez le thread JavaScript avec du travail synchrone coûteux, et la chaîne s’arrêtera à l’étape suivante :

User Types
    ↓
JS Thread Busy
████████████████
    ↓
React can't process JS work yet

L’application semble figée, même si le thread UI natif lui-même est parfaitement fonctionnel. Lors du diagnostic de lenteurs, il est utile de distinguer les causes possibles, car chacune nécessite une solution différente :

Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?

Trop d’appels de rendu exigent l’utilisation de la mémorisation, de la virtualisation ou des transitions. Un thread JavaScript bloqué nécessite le déplacement ou la séparation des tâches lourdes. Des opérations natives coûteuses ou un thread UI surchargé orientent plutôt vers un profilage natif.

Tout le processus

Lorsque l’on assemble tous les éléments, une seule interaction traverse ces étapes :

USER INTERACTION
       ↓
JavaScript Runtime
       ↓
Event Handler
       ↓
setState()
       ↓
Update Created
       ↓
Lane / Priority
       ↓
React Scheduler
       ↓
Choose Work
       ↓
Fiber Render
       ↓
Can Yield / Restart
       ↓
Reconciliation
       ↓
Completed Work
       ↓
Commit
       ↓
UI Updated

Tout cela a lieu à l’intérieur des limites du runtime JavaScript :

┌─────────────────────────────┐
│     JavaScript Runtime      │
│                             │
│ Event Loop gives JS a chance│
│            ↓                │
│     React coordinates       │
│        its own work         │
└─────────────────────────────┘

Le cycle d’événements attribue des tours à JavaScript ; à chaque tour, React décide comment l’utiliser.

Questions auxquelles il est utile de pouvoir répondre

Le planificateur de React est-il la même chose que le cycle d’événements ?

Non. Le cycle d’événements fait partie du runtime hôte. Le planificateur de React coordonne les tâches de React dans le temps alloué par ce runtime.

React concurrentiel exécute-t-il les composants sur plusieurs threads ?

Non. Le rendu concurrentiel permet à React de mettre en pause, de reprendre, de redémarrer ou d’abandonner les tâches de rendu sur un seul thread. Le code des composants ne devient pas parallèle.

React peut-il interrompre le rendu ?

Oui, dans le cas du rendu concurrentiel. React peut donner la priorité aux mises à jour urgentes et éliminer les tâches de rendu obsolètes avant qu’elles ne soient enregistrées.

Le planificateur peut-il sauver un thread bloqué ?

Non. Les scripts JavaScript synchrones à long exécution font attendre React, tout comme les autres éléments, jusqu’à ce que le thread soit libéré.

Pourquoi les composants doivent-ils rester purs ?

Car un rendu peut être exécuté plus d’une fois et peut être interrompu ou annulé. Les effets secondaires durant le rendu s’échapperaient du travail qui n’atteint jamais l’écran.

Comment évolue le modèle mental

La plupart des développeurs partent de l’idée que la modification de l’état entraîne directement un nouveau rendu :

setState()
   ↓
React renders
   ↓
Screen updates

Apprendre les concepts de files d’attente et de planification ajoute quelques étapes intermédiaires :

setState()
   ↓
Update
   ↓
Lane
   ↓
Scheduler
   ↓
Render
   ↓
Commit

La couche finale est le temps de exécution lui-même, qui se trouve en dessous de tout ce que fait React :

JavaScript Runtime
       ↓
Event Loop
       ↓
React Scheduler
       ↓
Fiber
       ↓
Render
       ↓
Commit

React planifie intelligemment son propre travail, mais toujours depuis l’environnement JavaScript. Vu de cette manière, les transitions, le rendu interrompable et un thread JavaScript bloqué forment un tout cohérent.

Où va se diriger ce processus

Cet article a suivi le parcours depuis une mise à jour d’état jusqu’au cycle d’événements :

State Update
    ↓
Update Queue
    ↓
Fiber
    ↓
Lanes
    ↓
Scheduler
    ↓
JavaScript Event Loop

Dans React Native, ce parcours se poursuit, allant du commit de React jusqu’aux vues réelles de la plateforme. Un arbre aussi simple que celui-ci :

<View>
  <Text>Hello React Native</Text>
</View>

il passe encore par le bundler, le moteur Hermes, JSI et le rendeur Fabric avant de devenir une interface utilisateur native pour Android ou iOS. Ces couches apportent leurs propres problématiques de threads et d’ordonnancement, et il est utile de les comprendre séparément une fois le côté JavaScript clarifié.

Points clés

  • Le cycle d’événements décide quand le JavaScript s’exécute ; l’ordonnanceur de React choisit quel travail React effectuer lorsqu’il dispose de ce temps.
  • L’ordonnancement de React est coopératif : il cède la parole entre les unités de travail Fiber afin que l’entrée et le rendu puissent se dérouler.
  • La concurrence dans React signifie l’intercalation des tâches sur un seul thread, et non une exécution parallèle.
  • startTransition indique que les mises à jour ne sont pas urgentes ; useDeferredValue permet à un composant de s’afficher en utilisant une valeur différée.
  • Aucun planificateur ne peut interrompre son propre code synchrone long, donc les calculs intensifs doivent toujours être optimisés ou déplacés.
  • Les commits ne prennent en compte que des résultats complets, et les rendus doivent être purs car ils peuvent être réessayés ou ignorés.
  • Une mise à jour à haute priorité ne peut pas s’afficher tant qu’un calcul synchrone de cinq secondes occupe le thread ; elle attend que ce code revienne.
  • Lectures complémentaires