web-dev-qa-db-fra.com

React hook rendu un temps supplémentaire

Mon code provoque une quantité inattendue de rendus.

function App() {    
    const [isOn, setIsOn] = useState(false)
    const [timer, setTimer] = useState(0)
    console.log('re-rendered', timer)

    useEffect(() => {
        let interval

        if (isOn) {
            interval = setInterval(() => setTimer(timer + 1), 1000)
        }

        return () => clearInterval(interval)
    }, [isOn])

    return (
      <div>
        {timer}
        {!isOn && (
          <button type="button" onClick={() => setIsOn(true)}>
            Start
          </button>
        )}

        {isOn && (
          <button type="button" onClick={() => setIsOn(false)}>
            Stop
          </button>
        )}
      </div>
    );
 }

Notez le fichier console.log sur la ligne 4. Ce à quoi je m'attendais, c'est ce qui suit pour être déconnecté:

rendu 0

rendu 0

rendu 1

Le premier journal est pour le rendu initial. Le deuxième journal est pour le nouveau rendu lorsque l'état "isOn" change via le clic du bouton. Le troisième journal est lorsque setInterval appelle setTimer afin qu'il soit à nouveau rendu. Voici ce que j'obtiens réellement:

rendu 0

rendu 0

rendu 1

rendu 1

Je ne peux pas comprendre pourquoi il y a un quatrième journal. Voici un lien vers un REPL de celui-ci:

https://codesandbox.io/s/kx393n58r7

*** Juste pour clarifier, je sais que la solution consiste à utiliser setTimer (timer => timer + 1), mais je voudrais savoir pourquoi le code ci-dessus provoque un quatrième rendu.

10
Adam Hartleb

La fonction avec l'essentiel de ce qui se passe lorsque vous appelez le setter retourné par useState est dispatchAction dans ReactFiberHooks.js (qui commence actuellement à la ligne 1009).

Le bloc de code qui vérifie si l'état a changé (et ignore potentiellement le nouveau rendu s'il n'a pas changé) est actuellement entouré de la condition suivante:

if (
  fiber.expirationTime === NoWork &&
  (alternate === null || alternate.expirationTime === NoWork)
) {

Mon hypothèse en voyant cela était que cette condition était évaluée à false après le deuxième appel setTimer. Pour vérifier cela, j'ai copié le CDN de développement React fichiers et ajouté quelques journaux de console à la fonction dispatchAction:

function dispatchAction(fiber, queue, action) {
  !(numberOfReRenders < RE_RENDER_LIMIT) ? invariant(false, 'Too many re-renders. React limits the number of renders to prevent an infinite loop.') : void 0;

  {
    !(arguments.length <= 3) ? warning$1(false, "State updates from the useState() and useReducer() Hooks don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().') : void 0;
  }
  console.log("dispatchAction1");
  var alternate = fiber.alternate;
  if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
    // This is a render phase update. Stash it in a lazily-created map of
    // queue -> linked list of updates. After this render pass, we'll restart
    // and apply the stashed updates on top of the work-in-progress hook.
    didScheduleRenderPhaseUpdate = true;
    var update = {
      expirationTime: renderExpirationTime,
      action: action,
      eagerReducer: null,
      eagerState: null,
      next: null
    };
    if (renderPhaseUpdates === null) {
      renderPhaseUpdates = new Map();
    }
    var firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
    if (firstRenderPhaseUpdate === undefined) {
      renderPhaseUpdates.set(queue, update);
    } else {
      // Append the update to the end of the list.
      var lastRenderPhaseUpdate = firstRenderPhaseUpdate;
      while (lastRenderPhaseUpdate.next !== null) {
        lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
      }
      lastRenderPhaseUpdate.next = update;
    }
  } else {
    flushPassiveEffects();

    console.log("dispatchAction2");
    var currentTime = requestCurrentTime();
    var _expirationTime = computeExpirationForFiber(currentTime, fiber);

    var _update2 = {
      expirationTime: _expirationTime,
      action: action,
      eagerReducer: null,
      eagerState: null,
      next: null
    };

    // Append the update to the end of the list.
    var _last = queue.last;
    if (_last === null) {
      // This is the first update. Create a circular list.
      _update2.next = _update2;
    } else {
      var first = _last.next;
      if (first !== null) {
        // Still circular.
        _update2.next = first;
      }
      _last.next = _update2;
    }
    queue.last = _update2;

    console.log("expiration: " + fiber.expirationTime);
    if (alternate) {
      console.log("alternate expiration: " + alternate.expirationTime);
    }
    if (fiber.expirationTime === NoWork && (alternate === null || alternate.expirationTime === NoWork)) {
      console.log("dispatchAction3");

      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      var _eagerReducer = queue.eagerReducer;
      if (_eagerReducer !== null) {
        var prevDispatcher = void 0;
        {
          prevDispatcher = ReactCurrentDispatcher$1.current;
          ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }
        try {
          var currentState = queue.eagerState;
          var _eagerState = _eagerReducer(currentState, action);
          // Stash the eagerly computed state, and the reducer used to compute
          // it, on the update object. If the reducer hasn't changed by the
          // time we enter the render phase, then the eager state can be used
          // without calling the reducer again.
          _update2.eagerReducer = _eagerReducer;
          _update2.eagerState = _eagerState;
          if (is(_eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // It's still possible that we'll need to rebase this update later,
            // if the component re-renders for a different reason and by that
            // time the reducer has changed.
            return;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          {
            ReactCurrentDispatcher$1.current = prevDispatcher;
          }
        }
      }
    }
    {
      if (shouldWarnForUnbatchedSetState === true) {
        warnIfNotCurrentlyBatchingInDev(fiber);
      }
    }
    scheduleWork(fiber, _expirationTime);
  }
}

et voici la sortie de la console avec quelques commentaires supplémentaires pour plus de clarté:

re-rendered 0 // initial render

dispatchAction1 // setIsOn
dispatchAction2
expiration: 0
dispatchAction3
re-rendered 0

dispatchAction1 // first call to setTimer
dispatchAction2
expiration: 1073741823
alternate expiration: 0
re-rendered 1

dispatchAction1 // second call to setTimer
dispatchAction2
expiration: 0
alternate expiration: 1073741823
re-rendered 1

dispatchAction1 // third and subsequent calls to setTimer all look like this
dispatchAction2
expiration: 0
alternate expiration: 0
dispatchAction3

NoWork a une valeur de zéro. Vous pouvez voir que le premier journal de fiber.expirationTime après setTimer a une valeur non nulle. Dans les journaux du deuxième appel setTimer, ce fiber.expirationTime a été déplacé vers alternate.expirationTime empêchant toujours la comparaison d'état, le nouveau rendu sera inconditionnel. Après cela, les temps d'expiration fiber et alternate sont à 0 (NoWork), puis il fait la comparaison des états et évite un nouveau rendu.

Cette description de React Fibre Architecture est un bon point de départ pour essayer de comprendre le but de expirationTime.

Les parties les plus pertinentes du code source pour le comprendre sont:

Je crois que les délais d'expiration sont principalement pertinents pour le mode simultané qui n'est pas encore activé par défaut. Le délai d'expiration indique le moment après lequel React forcera la validation du travail à la première occasion. Avant ce moment, React peut choisissez de mettre à jour par lots. Certaines mises à jour (telles que les interactions avec les utilisateurs) ont une expiration très courte (haute priorité), et d'autres mises à jour (telles que du code asynchrone après une extraction) ont une expiration plus longue (faible priorité). Les mises à jour déclenchées par setTimer à partir de la fonction de rappel setInterval tomberait dans la catégorie de faible priorité et pourrait potentiellement être groupée (si le mode simultané était activé). Puisqu'il est possible que ce travail ait été groupé ou potentiellement ignoré, React met en file d'attente un re-rendu inconditionnel (même lorsque l'état est inchangé depuis la mise à jour précédente) si la mise à jour précédente avait un expirationTime.

Vous pouvez voir ma réponse ici pour en savoir un peu plus sur la façon de trouver votre chemin à travers le code React pour accéder à cette fonction dispatchAction.

Pour ceux qui veulent creuser eux-mêmes, voici un CodeSandbox avec ma version modifiée de React:

Edit static

Les fichiers react sont des copies modifiées de ces fichiers:

https://unpkg.com/react@16/umd/react.development.js
https://unpkg.com/react-dom@16/umd/react-dom.development.js
10
Ryan Cogswell