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

Découvrez comment nous avons utilisé le fractionnement de code, l'intégration du code et l'affichage côté serveur dans PROXX.

Lors de la conférence Google I/O 2019, Mariko, Jake et moi avons lancé PROXX, un clone de démineur moderne pour le Web. PROXX se distingue par son accessibilité (vous pouvez jouer avec un lecteur d'écran) et la possibilité de fonctionner aussi bien sur un téléphone multifonction que sur un ordinateur de bureau haut de gamme. Les fonctionnalités pour les téléphones multimédias sont soumises à plusieurs contraintes:

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

Mais ils sont équipés d'un navigateur récent et abordables. Pour cette raison, les téléphones multifonctions reviennent 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. En 2019, environ 400 millions de téléphones multifonctions devraient être vendus rien qu'en Inde. Les utilisateurs de téléphones multifonctions pourraient donc représenter une part importante de votre audience. En outre, des vitesses de connexion semblables à celles de la 2G sont la norme dans les marchés émergents. Comment avons-nous réussi à faire en sorte que PROXX fonctionne bien avec les feature phones ?

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 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 se traduisent par une fidélisation accrue des utilisateurs, une amélioration des conversions et, surtout, une meilleure inclusion. Jeremy Wagner dispose de beaucoup plus de données et d'insights sur l'importance des performances.

Il s'agit de la première partie d'une série en deux parties. La première partie est consacrée aux performances de chargement, et la seconde partie est consacrée aux 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'un véritable appareil, nous vous recommandons d'utiliser WebPageTest, en particulier la méthode "simple" configuration. WPT exécute des tests de chargement de la batterie sur un vrai appareil avec une connexion 3G émulée.

La 3G est un bon moyen de mesurer la vitesse. Même si vous êtes peut-être habitué à la 4G, au LTE ou même bientôt à la 5G, l'Internet mobile en réalité est assez différent. Vous êtes peut-être dans un train, à une conférence, à un concert ou dans l'avion. Le problème que vous rencontrez est très proche de la 3G, voire pire.

Cela dit, nous allons nous concentrer sur la 2G dans cet article, car PROXX cible explicitement les téléphones multifonctions et les marchés émergents dans son audience cible. Une fois que WebPageTest a exécuté son test, vous obtenez une cascade (semblable à celle que vous voyez dans les outils de développement) ainsi qu'une pellicule en haut. La pellicule montre ce que l'utilisateur voit pendant le chargement de votre application. Sur la 2G, le chargement de la version non optimisée de PROXX n'est pas optimal:

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> La vidéo en 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.

Lorsqu'il est chargé en 3G, l'utilisateur voit un écran vide pendant 4 secondes. En 2G, l'utilisateur ne voit absolument rien pendant plus de huit secondes. Si vous lisez l'article Pourquoi les performances sont-elles importantes, vous savez que nous avons perdu une bonne partie de nos utilisateurs potentiels à cause de l'impatience. L'utilisateur doit télécharger l'ensemble des 62 Ko de JavaScript pour que tous les éléments s'affichent à 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…

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Dans la version non optimisée de PROXX, le [First Meaningful Paint][FMP] est _techniquement_ [interactif][TTI], mais ne sert à rien pour l'utilisateur.

Une fois que 62 Ko de code JS gzip ont été téléchargés et que le DOM a été généré, l'utilisateur peut voir notre application. L'application est techniquement interactive. Toutefois, en regardant l'image, on voit une autre réalité. Les polices Web continuent à se charger en arrière-plan et, jusqu'à ce qu'elles soient prêtes, l'utilisateur ne peut voir aucun texte. Bien que cet état soit qualifié de First Meaningful Paint (FMP), il n'est certainement pas aussi interactif, car l'utilisateur ne peut pas savoir à quoi correspondent les entrées. L'application met 3 secondes en 3G et 3 secondes en 2G pour être prête à l'emploi. Dans l'ensemble, il faut 6 secondes pour que l'application devienne interactive en 3G et 11 secondes en 2G.

Analyse en cascade

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

  1. Il y a plusieurs lignes fines de plusieurs couleurs.
  2. Les fichiers JavaScript forment une chaîne. Par exemple, le chargement de la deuxième ressource ne commence qu'une fois la première ressource terminée, et le chargement de la troisième ressource ne commence que lorsque la deuxième ressource est terminée.
<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> La cascade d'annonces vous permet de savoir quelles ressources se chargent, à 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 connexion HTTP. La configuration d'une nouvelle connexion est coûteuse, car il faut environ 1 seconde en 3G et environ 2,5 secondes 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
  • Demande n° 8: Google Analytics
  • Demande n° 9: fichier de police de fonts.gstatic.com
  • Requête n° 14: fichier manifeste d'application Web

La nouvelle connexion pour index.html est inévitable. Le navigateur doit créer une connexion à notre serveur pour récupérer le contenu. La nouvelle association avec Google Analytics pourrait être évitée en intégrant quelque chose comme Minimal Analytics, mais Google Analytics n'empêche pas notre application de s'afficher ni de devenir interactive. Par conséquent, la vitesse de chargement n'est pas très importante pour nous. Dans l'idéal, Google Analytics devrait être chargé en période d'inactivité, lorsque tous les autres éléments ont déjà été chargés. Ainsi, il n'utilisera pas de bande passante ni de puissance de traitement lors du chargement initial. La nouvelle connexion au fichier manifeste de l'application Web est préscrite par les spécifications de récupération, car le fichier manifeste doit être chargé via une connexion sans identifiant. Là encore, 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 y soucier beaucoup.

Toutefois, les deux polices et leurs styles posent problème, car elles bloquent l'affichage et l'interactivité. Si nous observons le code CSS diffusé par fonts.googleapis.com, il s'agit seulement 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, en supprimant une connexion inutile. Pour éviter les coûts de configuration de la connexion pour les fichiers de police, nous pouvons les copier sur notre propre serveur.

Chargements en parallèle

En regardant la cascade, nous pouvons voir qu'une fois le chargement du premier fichier JavaScript terminé, les nouveaux fichiers commencent immédiatement à se charger. Ceci est typique pour les dépendances de module. Notre module principal comporte 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 comprendre 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 les résultats obtenus grâce à nos modifications. Il est important de ne modifier aucune autre variable dans la configuration de test qui pourrait fausser les résultats. Nous allons donc utiliser la configuration simple de WebPageTest pour la suite de cet article et examiner la pellicule:

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Nous utilisons la pellicule de WebPageTest pour évaluer les résultats obtenus.

Ces modifications ont permis de faire passer notre TTI de 11 à 8,5, ce qui correspond à environ 2,5 secondes de temps de configuration de la connexion que nous avions l'intention de supprimer. Bien joué.

Prérendu

Bien que nous ayons simplement réduit nos TTI, nous n'avons pas vraiment affecté l'écran blanc interminable que l'utilisateur doit supporter 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. Ces 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 pour chaque requête côté serveur, tandis que le prérendu le fait au moment de la compilation et stocke le résultat en tant que nouvelle index.html. PROXX étant une application JAMStack sans serveur 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 à l'aide d'une API Node. Nous l'utilisons pour injecter notre balisage et notre JavaScript, puis relire le DOM en tant que chaîne de code 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);

Une fois que cela est en place, nous pouvons nous attendre à une amélioration de notre FMP. Nous devons encore charger et exécuter la même quantité de JavaScript qu'auparavant, et le TTI ne devrait donc pas changer beaucoup. Si notre index.html s'est développé, cela pourrait retarder un peu notre TTI. Il n'y a qu'une façon de le savoir: exécuter WebPageTest.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> La pellicule montre une nette amélioration de notre métrique FMP. Le TTI n'est presque pas affecté.

Notre First Meaningful Paint est passé de 8,5 secondes à 4,9 secondes, ce qui représente une nette amélioration. Notre TTI dure toujours environ 8,5 secondes.Il n'est donc pas du tout affecté par ce changement. Ce que nous avons fait ici, c'est un changement perceptuel. Certains pourraient même appeler cela un tour de main. En affichant un visuel intermédiaire du jeu, nous améliorons les performances de chargement perçues.

Intégrer

Le délai avant le premier octet (TTFB) est une autre métrique fournie par les outils de développement et WebPageTest. Il s'agit du temps qui s'écoule entre l'envoi du premier octet de la requête et le premier octet de la réponse reçue. Ce délai est souvent appelé délai aller-retour (DAR), bien qu'il existe techniquement une différence entre ces deux chiffres: le DAR n'inclut pas le temps de traitement de la requête côté serveur. DevTools et WebPageTest permettent de visualiser le TTFB avec une couleur claire dans le bloc requête/réponse.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 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 pousser vers le bas. Lorsque le client se rend compte qu'il doit extraire des ressources supplémentaires, celles-ci 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é de la mise en cache.

Notre CSS essentiel 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.

<ph type="x-smartling-placeholder">
</ph>
Grâce à l'intégration de 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 avons atteint le point où notre index.html contient tout ce qui est nécessaire pour l'affichage initial et le passage à l'interactivité. 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.

Fractionnement du 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 aussi tout le reste. Notre index.html fait environ 43 Ko. Mettons cela en relation avec les éléments avec lesquels l'utilisateur peut interagir au début: nous avons un formulaire pour configurer le jeu. Il contient quelques composants, un bouton de démarrage et probablement du code permettant de conserver les paramètres utilisateur et de les charger. C'est à peu près tout. 43 Ko me semble beaucoup.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Page de destination de PROXX. Seuls les composants critiques 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. Si vous déplacez tout ce qui n'est pas strictement requis pour l'interactivité dans un module à chargement différé, vous diminuerez considérablement le TTI.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 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 donc procéder à une répartition du code. La division du code divise votre bundle monolithique en parties plus petites qui peuvent être chargées de manière différée à 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 intégre tous les modules qui sont importés de manière statique. Tout ce que vous importez de manière dynamique sera placé dans son propre fichier et ne sera extrait du réseau qu'une fois l'appel import() exécuté. Bien sûr, accéder au réseau a un coût et ne doit être fait que si vous avez du temps libre. Le mantra ici est d'importer de manière statique les modules qui sont essentiellement nécessaires 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 scénario de Phil Walton, intitulé Idle jusqu'à la demande urgente, est idéal pour trouver un juste milieu entre le chargement différé et le chargement rapide.

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 ceci en place, nous pouvons utiliser une promesse de composant dans nos fonctions render(). Par exemple, le composant <Nebula>, qui affiche l'image de fond animée, est remplacé par une <div> vide lors du chargement du composant. Une fois le composant chargé et prêt à être utilisé, <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 tous ces éléments en place, nous avons réduit notre index.html à seulement 20 Ko, soit moins de la moitié de sa taille d'origine. Quel effet cela a-t-il sur le FMP et le TTI ? WebPageTest vous le confirmera !

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> La pellicule confirme: notre TTI est maintenant à 5,4 s. Une nette amélioration par rapport à nos onze d'origine.

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

Plus de tours de main

Si vous consultez la liste des modules critiques ci-dessus, vous constaterez que le moteur de rendu ne fait pas partie de ces modules. Bien entendu, le jeu ne peut pas démarrer tant que nous n'avons pas notre moteur de rendu pour effectuer le rendu. Nous pourrions désactiver le « Start » jusqu'à ce que notre moteur de rendu soit prêt à démarrer le jeu. Toutefois, nous avons constaté que l'utilisateur a généralement besoin de suffisamment de temps pour configurer les paramètres de son jeu, ce qui 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 "Start" (Démarrer). Dans les rares cas où l'utilisateur est plus rapide que sa connexion réseau, un simple écran de chargement s'affiche et attend la fin des modules restants.

Conclusion

Les mesures sont importantes. Pour éviter de passer du temps à résoudre des problèmes qui ne sont pas réels, nous vous recommandons de toujours commencer par mesurer avant de mettre en œuvre des optimisations. En outre, les mesures doivent être effectuées sur des appareils vrais avec une connexion 3G ou sur WebPageTest si aucun appareil réel n'est à portée de main.

La pellicule peut fournir des informations sur le ressent du chargement de votre application pour l'utilisateur. La cascade peut vous indiquer quelles ressources sont à l'origine de temps de chargement potentiellement longs. Voici une checklist d'actions que vous pouvez effectuer pour améliorer les performances de chargement:

  • Diffusez autant de composants que possible via une connexion.
  • Préchargez ou même les ressources intégrées requises pour le premier rendu et l'interactivité.
  • Préchargez votre application pour améliorer les performances de chargement perçues.
  • Utilisez une scission de code agressive afin de 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.