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 faibles
  • GPU faibles ou inexistants
  • Petits écrans sans saisie tactile
  • Quantité de mémoire très limitée

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. Leur prix permet à une toute nouvelle audience, qui n'en avait pas les moyens auparavant, de se connecter à Internet 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 les conditions d'un téléphone basique ?

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'insights 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 même à 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 (semblable à 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 de pellicule montre ce que l'utilisateur voit lorsque PROXX est en cours de chargement sur un véritable appareil bas de gamme 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 bon côté des choses dans ce scénario est que le second élément qui apparaît à l'écran 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.

Après avoir téléchargé environ 62 Ko de code JavaScript gzip et généré le DOM, 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 de plusieurs couleurs.
  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 n° 1: notre index.html
  • Requête n° 5: les 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 est déjà 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 observons le code CSS diffusé 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 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 les balises <link rel="preload"> pour nous assurer que toutes les dépendances commencent à se charger dès la réception de notre code HTML.

Résultats

Voyons ce que nos modifications ont permis d'obtenir. Il est important de ne pas modifier d'autres variables dans la configuration de notre test qui pourraient 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. L'amélioration majeure des FMP consiste sans doute à envoyer un balisage stylisé dans votre index.html. Les techniques courantes permettant d'atteindre cet objectif sont le prérendu et l'affichage côté serveur, qui sont étroitement liés et sont 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. Comme nous utilisons des modules CSS, nous intégrons sans frais les 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 que très peu 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. Ce que nous avons fait ici, c'est 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 signifie que la requête attend de recevoir le premier octet de la réponse.

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

Ce problème était à l'origine de la conception de la fonction Push/HTTP/2. 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. Le protocole HTTP/2 Push s'est avéré trop difficile et est considéré comme déconseillé. Cet espace problématique sera réexaminé lors de la standardisation 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 nous a permis de gagner une seconde sur notre TTI. 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 le code HTML est analysé et exécuté, l'application devient interactive.

Division du code agressive

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 carte source ou un outil similaire afin de décomposer la composition du bundle. Comme prévu, notre pack contient la logique du jeu, le moteur de rendu, l'écran gagnant, l'écran de perte et de nombreux 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 réduira considérablement le TTI.

L'analyse du contenu du fichier "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 comme Webpack, Rollup et Parcel prennent en charge la division du code à l'aide de la méthode import() dynamique. Le bundler analyse votre code et incorpore tous les modules importés statiquement. Tout ce que vous importez dynamiquement 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. N'attendez pas le dernier moment pour charger en différé les modules qui vont vraiment être 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é être un peu compliqué, car Preact ne peut pas gérer les composants chargés de manière différée dès la première utilisation. 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 effet cela a-t-il 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, la configuration des paramètres du jeu prend généralement suffisamment de temps pour que cela ne soit 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

Les mesures sont importantes. 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 checklist d'actions que vous pouvez effectuer 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é.

Ne manquez pas la deuxième partie, dans laquelle nous verrons comment optimiser les performances d'exécution sur les appareils hypercontraints.