Techniques permettant d'accélérer le chargement d'une application Web, même sur un feature phone

Comment nous avons utilisé le fractionnement du code, l'intégration du code et le rendu côté serveur dans PROXX.

Lors de Google I/O 2019, Mariko, Jake et moi-même avons lancé PROXX, un clone moderne de Minesweeper pour le Web. PROXX se distingue par son accent mis sur l'accessibilité (vous pouvez y jouer avec un lecteur d'écran !) et sa capacité à s'exécuter aussi bien sur un téléphone basique que sur un ordinateur de bureau haut de gamme. Les feature phones sont limités de plusieurs façons:

  • Processeurs peu performants
  • GPU faibles ou inexistants
  • Petits écrans sans saisie tactile
  • Quantités de mémoire très limitées

Toutefois, ils exécutent un navigateur moderne et sont très abordables. C'est pourquoi les téléphones basiques connaissent un regain d'intérêt sur les marchés émergents. Son prix permet à une toute nouvelle audience, qui ne pouvait pas se le permettre auparavant, de se connecter et d'utiliser le Web moderne. Selon les prévisions, environ 400 millions de feature phones seront vendus en Inde en 2019. Les utilisateurs de feature phones pourraient donc représenter une part importante de votre audience. De plus, les vitesses de connexion équivalentes à la 2G sont la norme sur les marchés émergents. Comment avons-nous réussi à faire fonctionner PROXX dans des conditions de téléphone bas de gamme ?

Gameplay PROXX.

Les performances sont importantes, y compris les performances de chargement et d'exécution. Il a été démontré que de bonnes performances sont associées à une meilleure rétention des utilisateurs, à une augmentation des conversions et, surtout, à une plus grande inclusivité. Jeremy Wagner dispose de beaucoup plus de données et d'informations sur pourquoi les performances sont importantes.

Il s'agit de la première partie d'une série en deux parties. La première partie est axée sur les performances de chargement, et la deuxième partie sur les performances d'exécution.

Capturer le statu quo

Il est essentiel de tester les performances de chargement sur un appareil réel. Si vous ne disposez pas d'appareil physique, nous vous recommandons d'utiliser WebPageTest, en particulier la configuration "simple". WPT exécute une série de tests de chargement sur un appareil réel avec une connexion 3G émulée.

La vitesse 3G est un bon point de départ. Vous êtes peut-être habitué à la 4G, à la LTE ou bientôt à la 5G, mais la réalité de l'Internet mobile est bien différente. Vous êtes peut-être dans un train, à une conférence, à un concert ou dans un avion. Vous bénéficierez probablement d'une qualité de connexion proche de la 3G, et parfois même pire.

Toutefois, nous allons nous concentrer sur la 2G dans cet article, car PROXX cible explicitement les feature phones et les marchés émergents dans son audience cible. Une fois que WebPageTest a exécuté son test, une cascade (similaire à celle que vous voyez dans DevTools) ainsi qu'une pellicule s'affichent en haut de l'écran. La pellicule montre ce que l'utilisateur voit pendant le chargement de votre application. En 2G, l'expérience de chargement de la version non optimisée de PROXX est assez mauvaise:

La vidéo en filmstrip montre ce que l'utilisateur voit lorsque PROXX se charge sur un appareil bas de gamme réel via une connexion 2G émulée.

Lors du chargement via la 3G, l'utilisateur voit un écran blanc pendant quatre secondes. Au-delà de 2 Go, l'utilisateur ne voit absolument rien pendant plus de huit secondes. Si vous avez lu Pourquoi les performances sont importantes, vous savez que nous avons maintenant perdu une bonne partie de nos utilisateurs potentiels en raison de leur impatience. L'utilisateur doit télécharger l'intégralité des 62 ko de code JavaScript pour que quoi que ce soit s'affiche à l'écran. Le point positif de ce scénario est que dès qu'un élément apparaît à l'écran, il est également interactif. ou presque…

Le [First Meaningful Paint][FMP] de la version non optimisée de PROXX est _techniquement_ [interactive][TTI], mais inutile pour l'utilisateur.

Une fois environ 62 ko de code JavaScript compressé par gzip téléchargés et le DOM généré, l'utilisateur peut voir notre application. L'application est techniquement interactive. Cependant, le visuel montre une réalité différente. Les polices Web sont toujours en cours de chargement en arrière-plan. Tant qu'elles ne sont pas prêtes, l'utilisateur ne voit aucun texte. Bien que cet état soit considéré comme un premier rendu significatif (FMP), il n'est certainement pas interactif, car l'utilisateur ne peut pas savoir à quoi correspondent les entrées. Il faut encore une seconde en 3G et trois secondes en 2G pour que l'application soit prête à l'emploi. Au total, l'application met six secondes en 3G et 11 secondes en 2G pour devenir interactive.

Analyse en cascade

Maintenant que nous savons ce que l'utilisateur voit, nous devons comprendre pourquoi. Pour ce faire, nous pouvons examiner la cascade et analyser pourquoi les ressources se chargent trop tard. Dans notre trace 2G pour PROXX, nous pouvons voir deux signaux d'alerte majeurs:

  1. Il y a plusieurs lignes fines multicolores.
  2. Les fichiers JavaScript forment une chaîne. Par exemple, la deuxième ressource ne commence à se charger qu'une fois la première terminée, et la troisième ne commence qu'une fois la deuxième terminée.
La cascade indique quelles ressources sont en cours de chargement, à quel moment et pendant combien de temps.

Réduire le nombre de connexions

Chaque ligne fine (dns, connect, ssl) représente la création d'une nouvelle connexion HTTP. La configuration d'une nouvelle connexion est coûteuse, car elle prend environ 1 s en 3G et environ 2,5 s en 2G. Dans notre cascade, nous voyons une nouvelle connexion pour:

  • Demande 1: Notre index.html
  • Demande 5: Styles de police de fonts.googleapis.com
  • Requête 8: Google Analytics
  • Demande 9: Fichier de police à partir de fonts.gstatic.com
  • Requête 14: Fichier manifeste de l'application Web

La nouvelle connexion pour index.html est inévitable. Le navigateur doit créer une connexion à notre serveur pour obtenir le contenu. La nouvelle connexion pour Google Analytics pourrait être évitée en insérant quelque chose comme Minimal Analytics, mais Google Analytics n'empêche pas notre application de s'afficher ni de devenir interactive. Nous ne nous soucions donc pas vraiment de la vitesse de chargement. Idéalement, Google Analytics doit être chargé pendant les temps d'inactivité, lorsque tout le reste a déjà été chargé. Ils ne consomment ainsi pas de bande passante ni de puissance de traitement lors de la charge initiale. La nouvelle connexion pour le fichier manifeste de l'application Web est précisée par la spécification de récupération, car le fichier manifeste doit être chargé via une connexion sans identifiants. Encore une fois, le fichier manifeste de l'application Web n'empêche pas notre application de s'afficher ni de devenir interactive. Nous n'avons donc pas besoin de nous en soucier.

Cependant, les deux polices et leurs styles posent problème, car ils bloquent le rendu et l'interactivité. Si nous examinons le CSS fourni par fonts.googleapis.com, il ne s'agit que de deux règles @font-face, une pour chaque police. Les styles de police sont si petits que nous avons décidé de les intégrer dans notre code HTML, ce qui a supprimé une connexion inutile. Pour éviter les coûts de configuration de la connexion pour les fichiers de polices, nous pouvons les copier sur notre propre serveur.

Paralléliser les charges

En examinant la cascade, nous pouvons voir qu'une fois le premier fichier JavaScript chargé, de nouveaux fichiers commencent à être chargés immédiatement. C'est typique des dépendances de module. Notre module principal contient probablement des importations statiques. Le code JavaScript ne peut donc pas s'exécuter tant que ces importations ne sont pas chargées. Il est important de noter que ces types de dépendances sont connus au moment de la compilation. Nous pouvons utiliser des balises <link rel="preload"> pour nous assurer que toutes les dépendances commencent à se charger dès que nous recevons le code HTML.

Résultats

Voyons ce que nos modifications ont permis d'obtenir. Il est important de ne modifier aucune autre variable de la configuration de test qui pourrait fausser les résultats. Nous allons donc utiliser la configuration simple de WebPageTest pour le reste de cet article et examiner la pellicule:

Nous utilisons la pellicule de film de WebPageTest pour voir l'impact de nos modifications.

Ces modifications ont réduit notre TTI de 11 à 8,5, soit environ 2,5 s de temps de configuration de la connexion que nous voulions supprimer. Bravo !

Prérendu

Bien que nous ayons réduit notre TTI, nous n'avons pas vraiment affecté l'écran blanc interminable que l'utilisateur doit endurer pendant 8,5 secondes. Les meilleures améliorations pour les FMP peuvent être obtenues en envoyant du balisage stylisé dans votre index.html. Pour ce faire, les techniques courantes sont le prérendu et le rendu côté serveur, qui sont étroitement liés et expliqués dans la section Affichage sur le Web. Les deux techniques exécutent l'application Web dans Node et sérialisent le DOM obtenu en HTML. Le rendu côté serveur effectue cette opération par requête côté serveur, tandis que le prérendu le fait au moment de la compilation et stocke la sortie en tant que nouveau index.html. Étant donné que PROXX est une application JAMStack et qu'elle n'a pas de côté serveur, nous avons décidé d'implémenter le prérendu.

Il existe de nombreuses façons d'implémenter un prérendu. Dans PROXX, nous avons choisi d'utiliser Puppeteer, qui lance Chrome sans interface utilisateur et vous permet de contrôler à distance cette instance avec une API Node. Nous l'utilisons pour injecter notre balisage et notre code JavaScript, puis pour lire le DOM en tant que chaîne HTML. Étant donné que nous utilisons des modules CSS, nous obtenons sans frais l'intégration CSS des styles dont nous avons besoin.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Nous pouvons donc nous attendre à une amélioration de notre FMP. Nous devons toujours charger et exécuter la même quantité de code JavaScript qu'auparavant. Nous ne devrions donc pas nous attendre à ce que le TTI change beaucoup. Notre index.html a augmenté et pourrait repousser notre TTI un peu. Il n'y a qu'une seule façon de le savoir: exécuter WebPageTest.

La pellicule montre une nette amélioration de notre métrique FMP. Le TTI n'est pratiquement pas affecté.

Notre First Meaningful Paint est passé de 8,5 secondes à 4,9 secondes, ce qui représente une amélioration considérable. Notre TTI se situe toujours autour de 8,5 secondes.Il n'a donc pas été affecté par ce changement. Nous avons ici effectué un changement perceptuel. Certains pourraient même parler de tour de passe-passe. En affichant une image intermédiaire du jeu, nous améliorons les performances de chargement perçues.

Intégration

DevTools et WebPageTest nous fournissent également une autre métrique : le temps de latence du premier octet (TTFB). Il s'agit du temps écoulé entre l'envoi du premier octet de la requête et la réception du premier octet de la réponse. Ce temps est également souvent appelé délai aller-retour (DAR), bien qu'il existe techniquement une différence entre ces deux valeurs: le DAR n'inclut pas le temps de traitement de la requête côté serveur. DevTools et WebPageTest visualisent le TTFB avec une couleur claire dans le bloc de requête/réponse.

La section claire d'une requête indique qu'elle attend de recevoir le premier octet de la réponse.

En examinant notre cascade, nous pouvons voir que toutes les requêtes passent la majorité de leur temps à attendre l'arrivée du premier octet de la réponse.

C'est pour résoudre ce problème que la fonctionnalité Push de HTTP/2 a été conçue à l'origine. Le développeur de l'application sait que certaines ressources sont nécessaires et peut les transmettre. Lorsque le client se rend compte qu'il doit récupérer des ressources supplémentaires, elles se trouvent déjà dans les caches du navigateur. La fonctionnalité Push HTTP/2 s'est avérée trop difficile à mettre en place et est déconseillée. Cet espace de problèmes sera réexaminé lors de la normalisation de HTTP/3. Pour l'instant, la solution la plus simple consiste à intégrer toutes les ressources critiques, au détriment de l'efficacité du cache.

Notre CSS critique est déjà intégré grâce aux modules CSS et à notre prérendu basé sur Puppeteer. Pour JavaScript, nous devons intégrer nos modules critiques et leurs dépendances. La difficulté de cette tâche varie en fonction du bundler que vous utilisez.

En insérant notre code JavaScript, nous avons réduit notre TTI de 8,5 s à 7,2 s.

Cela a réduit notre TTI de 1 seconde. Nous sommes maintenant arrivés au point où notre index.html contient tout ce qui est nécessaire pour le rendu initial et pour devenir interactif. Le code HTML peut s'afficher pendant le téléchargement, ce qui crée notre FMP. Dès que l'analyse et l'exécution du code HTML sont terminées, l'application devient interactive.

Fractionnement de code agressif

Oui, notre index.html contient tout ce qui est nécessaire pour devenir interactif. Mais en y regardant de plus près, il s'avère qu'il contient également tout le reste. Notre index.html fait environ 43 ko. Mettons cela en relation avec ce avec quoi l'utilisateur peut interagir au début: nous avons un formulaire pour configurer le jeu contenant quelques composants, un bouton de démarrage et probablement du code pour conserver et charger les paramètres utilisateur. C'est à peu près tout. 43 ko me semblent beaucoup.

Page de destination de PROXX. Seuls les composants essentiels sont utilisés ici.

Pour comprendre d'où vient la taille de notre bundle, nous pouvons utiliser un explorateur de mappe source ou un outil similaire pour comprendre de quoi il est composé. Comme prévu, notre bundle contient la logique de jeu, le moteur de rendu, l'écran de victoire, l'écran de défaite et un tas d'utilitaires. Seul un petit sous-ensemble de ces modules est nécessaire pour la page de destination. Déplacer tout ce qui n'est pas strictement nécessaire à l'interactivité dans un module chargé de manière paresseuse permet de réduire considérablement le TTI.

Une analyse du contenu de l'index.html de PROXX révèle de nombreuses ressources inutiles. Les ressources critiques sont mises en évidence.

Nous devons effectuer un fractionnement du code. La division du code divise votre bundle monolithique en parties plus petites qui peuvent être chargées en différé à la demande. Les bundlers populaires tels que Webpack, Rollup et Parcel prennent en charge la division du code à l'aide de import() dynamiques. Le bundler analyse votre code et incorpore tous les modules importés statiquement. Tout ce que vous importez de manière dynamique sera placé dans son propre fichier et ne sera récupéré sur le réseau qu'une fois l'appel import() exécuté. Bien entendu, l'accès au réseau a un coût et ne doit être effectué que si vous avez le temps. Le mantra ici est d'importer de manière statique les modules essentiels au moment du chargement et de charger dynamiquement tout le reste. Toutefois, vous ne devez pas attendre le dernier moment pour charger de manière différée les modules qui seront certainement utilisés. Le modèle Idle Until Urgent (Inactif jusqu'à ce que ce soit urgent) de Phil Walton est un excellent compromis entre le chargement paresseux et le chargement anticipé.

Dans PROXX, nous avons créé un fichier lazy.js qui importe de manière statique tout ce dont nous n'avons pas besoin. Dans notre fichier principal, nous pouvons ensuite importer lazy.js de manière dynamique. Cependant, certains de nos composants Preact se sont retrouvés dans lazy.js, ce qui s'est avéré un peu compliqué, car Preact ne peut pas gérer les composants chargés de manière paresseuse. C'est pourquoi nous avons écrit un petit wrapper de composant deferred qui nous permet d'afficher un espace réservé jusqu'à ce que le composant réel soit chargé.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Une fois cette étape effectuée, nous pouvons utiliser une promesse d'un composant dans nos fonctions render(). Par exemple, le composant <Nebula>, qui affiche l'image de fond animée, sera remplacé par un <div> vide pendant le chargement du composant. Une fois le composant chargé et prêt à l'emploi, <div> est remplacé par le composant réel.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Une fois tout cela mis en place, nous avons réduit notre index.html à seulement 20 ko, soit moins de la moitié de la taille d'origine. Quel est l'impact de cette modification sur le FMP et le TTI ? WebPageTest vous le dira !

La pellicule confirme: notre TTI est maintenant de 5,4 s. Une amélioration considérable par rapport à nos 11 pouces d'origine.

Notre FMP et notre TTI ne sont séparés que par 100 ms, car il ne s'agit que d'analyser et d'exécuter le code JavaScript intégré. En seulement 5,4 secondes sur 2G, l'application est entièrement interactive. Tous les autres modules moins essentiels sont chargés en arrière-plan.

Plus de tours de passe-passe

Si vous consultez la liste des modules critiques ci-dessus, vous constaterez que le moteur de rendu ne fait pas partie des modules critiques. Bien sûr, le jeu ne peut pas démarrer tant que nous n'avons pas de moteur de rendu pour l'afficher. Nous pourrions désactiver le bouton "Démarrer" jusqu'à ce que notre moteur de rendu soit prêt à démarrer le jeu, mais d'après notre expérience, l'utilisateur prend généralement suffisamment de temps pour configurer les paramètres de son jeu que cela n'est pas nécessaire. La plupart du temps, le moteur de rendu et les autres modules restants sont chargés au moment où l'utilisateur appuie sur "Démarrer". Dans les rares cas où l'utilisateur est plus rapide que sa connexion réseau, un écran de chargement simple s'affiche, attendant la fin des modules restants.

Conclusion

Il est important de mesurer. Pour éviter de perdre du temps sur des problèmes qui ne sont pas réels, nous vous recommandons de toujours effectuer des mesures avant d'implémenter des optimisations. De plus, les mesures doivent être effectuées sur des appareils réels connectés en 3G ou sur WebPageTest si aucun appareil réel n'est disponible.

La pellicule peut vous donner un aperçu de l'expérience ressentie par l'utilisateur lors du chargement de votre application. La cascade vous indique les ressources responsables des temps de chargement potentiellement longs. Voici une liste de mesures que vous pouvez prendre pour améliorer les performances de chargement:

  • Transmettez autant d'éléments que possible via une seule connexion.
  • Préchargez ou même insérez les ressources requises pour le premier rendu et l'interactivité.
  • Préchargez votre application pour améliorer les performances de chargement perçues.
  • Utilisez une division du code agressive pour réduire la quantité de code nécessaire à l'interactivité.

Restez à l'écoute pour la partie 2, qui explique comment optimiser les performances d'exécution sur les appareils hypercontraints.