Catégories
Astuces et Design

Image de pré-mise en cache avec React Suspense

Suspense est une fonctionnalité passionnante et à venir de React qui permettra aux développeurs d'autoriser facilement leurs composants à retarder le rendu jusqu'à ce qu'ils soient «prêts», ce qui permettra une expérience utilisateur beaucoup plus fluide. «Prêt», dans ce contexte, peut signifier un certain nombre de choses. Par exemple, votre utilitaire de chargement de données peut être lié à Suspense, permettant d'afficher des états de chargement cohérents lorsque des données sont en vol, sans avoir besoin de suivre manuellement l'état de chargement par requête. Ensuite, lorsque vos données sont disponibles et que votre composant est «prêt», il sera rendu. C’est le sujet qui est le plus souvent discuté avec Suspense, et j’ai déjà écrit à ce sujet; cependant, le chargement de données n'est qu'un cas d'utilisation parmi d'autres où Suspense peut améliorer l'expérience utilisateur. Un autre sujet dont je veux parler aujourd'hui est le préchargement des images.

Avez-vous déjà créé ou utilisé une application Web où, après avoir atterri sur un écran, votre place dessus chancelle et saute au fur et à mesure que les images sont téléchargées et rendues? Nous appelons cela la redistribution de contenu et cela peut être à la fois choquant et désagréable. Le suspense peut y contribuer. Vous savez comment j'ai dit que Suspense consiste à empêcher un composant de s'afficher jusqu'à ce qu'il soit prêt? Heureusement, «prêt» dans ce contexte est assez ouvert – et pour nos besoins, il peut inclure «des images dont nous avons besoin et qui ont été préchargées». Voyons comment!

Cours accéléré rapide sur Suspense

Avant de plonger dans les détails, examinons rapidement le fonctionnement de Suspense. Il comporte deux parties principales. Le premier est le concept de suspension d'un composant. Cela signifie que React tente de rendre notre composant, mais il n'est pas "prêt". Lorsque cela se produit, le «secours» le plus proche dans l'arborescence des composants sera rendu. Nous allons chercher à faire des solutions de secours sous peu (c'est assez simple), mais la façon dont un composant indique à React qu'il n'est pas prêt est en lançant une promesse. React saisira cette promesse, réalisera que le composant n'est pas prêt et rendra la solution de secours. Lorsque la promesse se résout, React tentera à nouveau de rendre. Rincer, laver et répéter. Oui, je simplifie un peu les choses, mais c'est l'essentiel du fonctionnement de Suspense et nous développerons certains de ces concepts au fur et à mesure.

La deuxième partie de Suspense est l'introduction de mises à jour d'état «de transition». Cela signifie que nous définissons l'état, mais disons à React que le changement d'état peut entraîner la suspension d'un composant, et si cela se produit, à ne pas rendre une solution de secours. Au lieu de cela, nous voulons continuer à afficher l'écran actuel, jusqu'à ce que la mise à jour de l'état soit prête, moment auquel elle sera rendue. Et, bien sûr, React nous fournit un indicateur booléen «en attente» qui permet au développeur de savoir que cela est en cours afin que nous puissions fournir des commentaires de chargement en ligne.

Préchargeons quelques images!

Tout d'abord, je tiens à noter qu'il y a une démonstration complète de ce que nous faisons à la fin de cet article. N'hésitez pas à ouvrir la démo maintenant si vous souhaitez simplement vous lancer dans le code. Il montrera comment précharger des images avec Suspense, combiné avec des mises à jour de l'état de transition. Le reste de cet article va construire ce code étape par étape, en expliquant le comment et le pourquoi en cours de route.

OK allons-y!

Nous voulons que notre composant soit suspendu jusqu'à ce que toutes ses images soient préchargées. Pour rendre les choses aussi simples que possible, faisons un composant qui reçoit un src attribut, précharge l'image, gère la levée d'exception, puis rend un quand tout est prêt. Un tel composant nous permettrait de supprimer de manière transparente notre composant là où nous voulons qu'une image soit affichée, et Suspense gérerait le grognement du travail de s'accrocher jusqu'à ce que tout soit prêt.

Nous pouvons commencer par faire une esquisse préliminaire du code:

const SuspenseImg = ({ src, ...rest }) => {
  // todo: preload and throw somehow
  return ;
}; 

Nous avons donc deux choses à régler: (1) comment précharger une image, et (2) lier lors du lancement d'exceptions. La première partie est assez simple. Nous sommes tous habitués à utiliser des images en HTML via mais on peut aussi créer des images impérativement en utilisant le Image() objet en JavaScript; de plus, les images que nous créons comme celle-ci ont un rappel onload qui se déclenche lorsque l'image est… chargée. Cela ressemble à ceci:

const img = new Image();
img.onload = () => {
  // image is loaded
}; 

Mais comment lier cela au lancement d'exceptions? Si vous êtes comme moi, votre première inclination pourrait être quelque chose comme ceci:

const SuspenseImg = ({ src, ...rest }) => {
  throw new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      resolve();
    };
  });
  return ;
}; 

Le problème, bien sûr, est que cela va toujours jeter une promesse. Chaque fois que React tente de rendre un exemple, une nouvelle promesse sera créée et lancée rapidement. Au lieu de cela, nous voulons seulement lancer une promesse jusqu'à ce que l'image soit chargée. Il y a un vieil adage selon lequel tous les problèmes informatiques peuvent être résolus en ajoutant une couche d'indirection (à l'exception du problème de trop de couches d'indirection), alors faisons exactement cela et construisons un cache d'images. Quand nous lisons un src, le cache vérifiera s'il a chargé cette image, et sinon, il commencera le préchargement et lèvera l'exception. Et, si l'image est préchargée, elle retournera simplement true et laissera React continuer à rendre notre image.

Voici ce que notre le composant ressemble à:

export const SuspenseImg = ({ src, ...rest }) => {
  imgCache.read(src);
  return ;
};

Et voici à quoi ressemble une version minimale de notre cache:

const imgCache = {
  __cache: {},
  read(src) {
    if (!this.__cache(src)) {
      this.__cache(src) = new Promise((resolve) => {
        const img = new Image();
        img.onload = () => {
          this.__cache(src) = true;
          resolve(this.__cache(src));
        };
        img.src = src;
      }).then((img) => {
        this.__cache(src) = true;
      });
    }
    if (this.__cache(src) instanceof Promise) {
      throw this.__cache(src);
    }
    return this.__cache(src);
  }
};

Ce n’est pas parfait, mais c’est assez bon pour le moment. Allons-y et mettons-le à profit.

La mise en oeuvre

N'oubliez pas qu'il existe un lien vers la démo entièrement fonctionnelle ci-dessous, donc si je vais trop vite à une étape particulière, ne désespérez pas. Nous allons également vous expliquer les choses.

Commençons par définir notre solution de repli. Nous définissons un repli en plaçant une balise Suspense dans notre arborescence de composants, et passons notre repli via le fallback soutenir. Tout composant suspendu recherchera vers le haut la balise Suspense la plus proche et rendra sa solution de secours (mais si aucune balise Suspense n'est trouvée, une erreur sera générée). Une vraie application contiendrait probablement de nombreuses balises Suspense, définissant des solutions de secours spécifiques pour ses différents modules, mais pour cette démo, nous n'avons besoin que d'un seul encapsulant notre application racine.

function App() {
  return (
    }>
      
    
  );
}

le Le composant est un spinner de base, mais dans une vraie application, vous voudrez probablement rendre une sorte de coque vide du composant réel que vous essayez de rendre, pour offrir une expérience plus transparente.

Avec cela en place, notre Le composant rend finalement nos images avec ceci:


  {images.map(img => (
    
         
  ))}

Lors du chargement initial, notre spinner de chargement affichera, jusqu'à ce que nos images initiales soient prêtes, à quel point elles s'affichent toutes en même temps, sans aucune saccadé de redistribution échelonnée.

Mise à jour de l'état de transition

Une fois les images en place, lorsque nous en chargeons le prochain lot, nous aimerions qu'elles apparaissent après leur chargement, bien sûr, mais gardons les images existantes à l'écran pendant leur chargement. Nous faisons cela avec le useTransition crochet. Cela renvoie un startTransition fonction, et un isPending boolean, qui indique que notre mise à jour d'état est en cours, mais qu'elle a été suspendue (ou même si elle n'a pas été suspendue, peut toujours être vraie si la mise à jour de l'état prend tout simplement trop de temps). Enfin, lors de l'appel useTransition, vous devez passer un timeoutMs valeur, qui est la durée maximale pendant laquelle isPending le drapeau peut être true, avant que React n'abandonne et ne rende le repli (notez que le timeoutMs l'argument sera probablement supprimé dans un proche avenir, les mises à jour de l'état de transition attendant simplement aussi longtemps que nécessaire lors de la mise à jour du contenu existant).

Voici à quoi ressemble le mien:

const (startTransition, isPending) = useTransition({ timeoutMs: 10000 });

Nous allons laisser passer 10 secondes avant nos émissions de secours, ce qui est probablement trop long dans la vie réelle, mais convient aux besoins de cette démo, en particulier lorsque vous ralentissez délibérément la vitesse de votre réseau dans DevTools pour expérimenter.

Voici comment nous l’utilisons. Lorsque vous cliquez sur le bouton pour charger plus d'images, le code ressemble à ceci:

startTransition(() => {
  setPage(p => p + 1);
});

Cette mise à jour de l'état déclenchera un nouveau chargement de données à l'aide de mon client GraphQL micro-graphql-react, qui, étant compatible Suspense, nous lancera une promesse pendant que la requête est en cours. Une fois que les données reviennent, notre composant tentera d'effectuer le rendu et de suspendre à nouveau pendant le préchargement de nos images. Pendant que tout cela se produit, notre isPending la valeur sera true, ce qui nous permettra d'afficher un spinner de chargement au dessus de notre contenu existant.

Éviter les cascades du réseau

Vous vous demandez peut-être comment React bloque le rendu pendant le préchargement de l'image. Avec le code ci-dessus, lorsque nous faisons cela:

{images.map(img => (

… Avec notre rendu dans celui-ci, React tentera de rendre la première image, Suspend, puis réessayera la liste, dépassera la première image, qui est maintenant dans notre cache, uniquement pour suspendre sur la deuxième image, puis la troisième, la quatrième, etc. Si vous avez déjà lu Suspense, vous vous demandez peut-être si nous devons précharger manuellement toutes les images de notre liste. avant tout ce rendu se produit.

Il s'avère qu'il n'y a pas lieu de s'inquiéter, et pas besoin de préchargement gênant car React est assez intelligent sur la façon dont il rend les choses dans un monde à suspense. Alors que React se fraye un chemin dans notre arborescence de composants, il ne s'arrête pas simplement lorsqu'il touche une suspension. Au lieu de cela, il continue le rendu de tous les autres chemins via notre arborescence de composants. Donc, oui, quand il tente de rendre l'image zéro, une suspension se produit, mais React continue d'essayer de rendre les images 1 à N, et ensuite seulement.

Vous pouvez voir cela en action en regardant l'onglet Réseau dans la démo complète, lorsque vous cliquez sur le bouton «Images suivantes». Vous devriez voir le seau entier d'images apparaître immédiatement dans la liste du réseau, résoudre un par un, et lorsque tout est terminé, les résultats devraient apparaître à l'écran. Pour vraiment amplifier cet effet, vous pouvez réduire la vitesse de votre réseau à «Fast 3G».

Pour le plaisir, nous pouvons forcer Suspense à tomber en cascade sur nos images en lisant manuellement chaque image de notre cache avant React tente de rendre notre composant, en parcourant chaque chemin de l'arborescence des composants.

images.forEach((img) => imgCache.read(img));

J'ai créé une démo qui illustre cela. Si vous regardez de la même manière l'onglet Réseau lorsqu'un nouvel ensemble d'images arrive, vous les verrez ajoutés séquentiellement dans la liste des réseaux (mais ne pas exécutez ceci avec la vitesse de votre réseau ralentie).

Suspendre tard

Il y a un corollaire à garder à l'esprit lors de l'utilisation de Suspense: suspendre le plus tard dans le rendu et aussi bas que possible dans l'arborescence des composants. Si vous avez une sorte de qui rend un tas d'images suspendues, assurez-vous que chaque image est suspendue dans son propre composant afin que React puisse l'atteindre séparément, et ainsi aucune ne bloquera les autres, ce qui entraînera une cascade.

La version de chargement des données de cette règle est que les données doivent être chargées le plus tard possible par les composants qui en ont réellement besoin. Cela signifie que nous devrions éviter de faire quelque chose comme ça dans un seul composant:

const { data1 } = useSuspenseQuery(QUERY1, vars1);
const { data2 } = useSuspenseQuery(QUERY2, vars2);

La raison pour laquelle nous voulons éviter cela est que la requête 1 sera suspendue, suivie de la requête 2, provoquant une cascade. Si cela est tout simplement inévitable, nous devrons précharger manuellement les deux requêtes avant les suspensions.

La démo

Voici la démo que j'ai promise. C'est le même que celui que j'ai associé ci-dessus.

Si vous l'exécutez avec vos outils de développement ouverts, assurez-vous de décocher la case "Désactiver le cache" dans l'onglet Réseau DevTools, sinon vous allez annuler la démo entière.

Le code est presque identique à ce que j'ai montré plus tôt. Une amélioration de la démo est que notre méthode de lecture du cache a cette ligne:

setTimeout(() => resolve({}), 7000);

C'est bien d'avoir toutes nos images bien préchargées, mais dans la vraie vie, nous ne voulons probablement pas retarder le rendu indéfiniment simplement parce qu'une ou deux images traînantes arrivent lentement. Donc, après un certain temps, nous donnons simplement le feu vert, même si l'image n'est pas encore prête. L’utilisateur verra une image ou deux scintiller, mais c’est mieux que de supporter la frustration des logiciels figés. Je noterai également que sept secondes sont probablement excessives, mais pour cette démo, je suppose que les utilisateurs ralentissent peut-être la vitesse du réseau dans DevTools pour voir plus clairement les fonctionnalités de Suspense et souhaitent le soutenir.

La démo comporte également une case à cocher pour les images de précache. Elle est cochée par défaut, mais vous pouvez la décocher pour remplacer la composant avec un ol régulier » balise, si vous souhaitez comparer la version Suspense à "normal React" (ne la vérifiez pas pendant que les résultats arrivent, sinon toute l'interface utilisateur peut se suspendre et restituer le remplacement).

Enfin, comme toujours avec CodeSandbox, certains états peuvent parfois se désynchroniser, alors appuyez sur le bouton d'actualisation si les choses commencent à paraître bizarres ou cassées.

Bouts

Il y a eu un bug massif que j'ai accidentellement fait lors de la mise en place de cette démo. Je ne voulais pas que plusieurs exécutions de la démo perdent leur effet, car le navigateur met en cache les images déjà téléchargées. Je modifie donc manuellement toutes les URL avec un cache buster:

const (cacheBuster, setCacheBuster) = useState(INITIAL_TIME);


const { data } = useSuspenseQuery(GET_IMAGES_QUERY, { page });
const images = data.allBooks.Books.map(
  (b) => b.smallImage + `?cachebust=${cacheBuster}`
);

INITIAL_TIME est défini au niveau des modules (c'est-à-dire globalement) avec cette ligne:

const INITIAL_TIME = +new Date();

Et si vous vous demandez pourquoi je n'ai pas fait cela à la place:

const (cacheBuster, setCacheBuster) = useState(+new Date());

… C'est parce que cela fait des choses horribles et horribles. Sur première rendre, les images tentent de rendre. Le cache provoque une suspension et React annule le rendu et affiche notre solution de secours. Lorsque toutes les promesses seront résolues, React tentera à nouveau ce rendu initial, et notre useState appel sera rediffusion, ce qui signifie que ceci:

const (cacheBuster, setCacheBuster) = useState(+new Date());

… Sera réexécuté, avec un Nouveau valeur initiale, provoquant une Nouveau un ensemble d'URL d'images, qui seront à nouveau suspendues, À l'infini. Le composant ne fonctionnera jamais et la démo de CodeSandbox s'arrête (ce qui rend le débogage frustrant).

Cela peut sembler un problème ponctuel étrange causé par une exigence unique pour cette démo particulière, mais il y a une leçon plus large: le rendu doit être pur, sans effets secondaires. React devrait être capable de réessayer le rendu de votre composant un certain nombre de fois, et (étant donné les mêmes accessoires initiaux) le même état exact devrait sortir à l'autre extrémité.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *