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

Utilisation de la division du code, de l'intégration de code et du rendu côté serveur dans PROXX

Lors de la conférence Google I/O 2019, Mariko, Jake et moi avons lancé PROXX, un clone moderne du démineur pour le Web. L'une des caractéristiques uniques de PROXX est son accessibilité (vous pouvez jouer avec un lecteur d'écran) et la possibilité de l'utiliser aussi bien sur un téléphone multimédia que sur un ordinateur de bureau haut de gamme. Les fonctionnalités liées aux téléphones multimédias sont soumises à différentes 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 sont très abordables. C'est pourquoi les téléphones multifonctions font leur retour dans les marchés émergents. Leur prix permet à une toute nouvelle audience, qui ne pouvait pas se le permettre auparavant, de se connecter et d'utiliser le Web moderne. Pour 2019, environ 400 millions de feature phones devraient être vendues rien qu'en Inde. Par conséquent, les utilisateurs de feature phones pourraient devenir une part importante de votre audience. Par ailleurs, les vitesses de connexion comparables à celles de la 2G sont la norme dans les marchés émergents. Comment avons-nous réussi à faire en sorte que PROXX fonctionne bien sur les feature phones ?

Gameplay PROXX.

Les performances sont importantes, et cela inclut à la fois les performances de chargement et d'exécution. Il a été démontré qu'une bonne performance est corrélée à une meilleure fidélisation des utilisateurs, à de meilleures 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, tandis que la deuxième est consacrée aux performances d'exécution.

Capturer le statu quo

Il est essentiel de tester vos performances de chargement sur un appareil réel. Si vous ne disposez pas d'un véritable appareil sous la main, nous vous recommandons WebPageTest, et plus particulièrement la configuration"simple". WPT exécute une batterie de tests de chargement sur un appareil réel avec une connexion 3G émulée.

La 3G est un bon débit à mesurer. Si vous êtes peut-être habitué à la 4G, au LTE ou bientôt à la 5G, la réalité de l'Internet mobile est très différente. Par exemple, vous êtes dans un train, une conférence, un concert ou un avion. Vous y rencontrerez probablement des problèmes plus proches de la 3G, et parfois même 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. Une fois que WebPageTest a effectué son test, vous obtenez une cascade (semblable à celle que vous voyez dans les outils de développement) ainsi qu'une pellicule en haut de l'écran. La pellicule montre ce que voit l'utilisateur pendant le chargement de votre application. En 2G, l'expérience de chargement de la version non optimisée de PROXX est plutôt mauvaise:

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

Lors du chargement en 3G, l'utilisateur voit 4 secondes de néant blanc. Avec la 2G, l'utilisateur ne voit absolument rien pendant plus de 8 secondes. Si vous lisez l'article expliquant pourquoi les performances sont importantes, vous savez que nous avons maintenant perdu une bonne partie de nos utilisateurs potentiels à cause de leur impatience. L'utilisateur doit télécharger l'intégralité des 62 Ko de JavaScript pour qu'un contenu s'affiche à l'écran. Le bon côté de ce scénario est que dès que quelque chose apparaît à l'écran, il est également interactif. ou presque…

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

Une fois que l'utilisateur a téléchargé environ 62 Ko de code JS gzip et généré le DOM, l'utilisateur peut voir notre application. Celle-ci est techniquement interactive. Toutefois, l'aspect visuel montre une réalité différente. Les polices Web sont toujours en cours de chargement en arrière-plan et tant qu'elles ne sont pas 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 correctement, car l'utilisateur ne peut pas dire à quoi correspondent les entrées. Il faut attendre encore 6 secondes en 3G et 3 secondes en 2G pour que l'application soit prête à l'emploi. Au total, il faut 6 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 déterminer le 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'alerte principaux:

  1. Il y a plusieurs lignes fines et multicolores.
  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.
La cascade indique quelles ressources se chargent, à quel moment et 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 cela prend environ 1 seconde en 3G et environ 2,5 s en 2G. Dans notre cascade, nous voyons une nouvelle connexion pour:

  • Demande n° 1: notre index.html
  • Demande n° 5: les styles de police de fonts.googleapis.com
  • Demande n° 8: Google Analytics
  • Demande n° 9: un fichier de police provenant de fonts.gstatic.com
  • Requête n° 14: fichier manifeste de l'application Web

La nouvelle connexion pour index.html est inévitable. Le navigateur doit établir une connexion avec notre serveur pour récupérer le contenu. Nous pourrions éviter cette nouvelle connexion à Google Analytics en intégrant, par exemple, Minimal Analytics. Toutefois, Google Analytics n'empêche pas notre application de s'afficher ou de devenir interactive. Nous ne nous soucions donc pas vraiment de la vitesse de chargement de celle-ci. Idéalement, Google Analytics devrait être chargé en temps d'inactivité, lorsque tout le reste a déjà été chargé. Ainsi, elle n'utilisera pas la bande passante ni la puissance de traitement lors du chargement initial. La nouvelle connexion au fichier manifeste de l'application Web est remplie par la spécification 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 ou de devenir interactive. Nous n'avons donc pas besoin de nous en soucier.

Cependant, ces deux polices et leurs styles posent problème, car elles bloquent l'affichage et l'interactivité. Prenons l'exemple du CSS fourni par fonts.googleapis.com. Il s'agit simplement de deux règles @font-face, une pour chaque police. Les styles de police sont si petits que nous avons décidé de l'intégrer à notre code HTML, éliminant ainsi une connexion inutile. Pour éviter le coût de configuration de la connexion pour les fichiers de police, nous pouvons les copier sur notre propre serveur.

Parallélisation des charges

En observant la cascade d'annonces, nous pouvons voir qu'une fois le chargement du premier fichier JavaScript terminé, le chargement des nouveaux fichiers commence immédiatement. C'est typiquement le cas pour les dépendances de module. Notre module principal comporte probablement des importations statiques. Le code JavaScript ne peut donc pas s'exécuter tant que ces importations n'ont pas été chargées. La chose importante à comprendre ici est 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 le chargement de toutes les dépendances commence dès la réception de notre code HTML.

Résultats

Examinons les résultats obtenus. Dans la configuration de test, il est important de ne modifier aucune autre variable susceptible de fausser les résultats. Pour le reste de cet article, nous allons donc utiliser la configuration simple de WebPageTest et examiner la pellicule:

Nous utilisons la pellicule de WebPageTest pour connaître les résultats de nos modifications.

Grâce à ces modifications, notre TTI est passé de 11 à 8,5, soit à peu près le temps de configuration de la connexion de 2,5 secondes que nous avions décidé de supprimer. Bravo !

Prérendu

Même si nous venons de réduire notre TTI, nous n'avons pas vraiment affecté la longueur éternelle de l'écran blanc que l'utilisateur doit supporter pendant 8,5 secondes. On peut penser que les améliorations les plus importantes de FMP peuvent être obtenues en envoyant un balisage stylisé dans votre index.html. Des techniques courantes permettant d'atteindre cet objectif sont le prérendu et le rendu côté serveur. Ils 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 à la demande côté serveur, tandis que le prérendu le fait au moment de la compilation et stocke la sortie en tant que nouvelle index.html. PROXX étant une application JAMStack sans 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 démarre 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 relire le DOM comme une chaîne de code HTML. Comme nous utilisons des modules CSS, nous intégrons sans frais les styles CSS 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 cela en place, nous pouvons nous attendre à une amélioration pour notre FMP. Nous devons encore charger et exécuter la même quantité de JavaScript qu'auparavant. Le TTI ne devrait donc pas changer beaucoup. Le cas échéant, notre index.html est devenu plus grand et pourrait repousser un peu notre TTI. Pour le savoir, vous pouvez exécuter WebPageTest.

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

La durée de la première peinture est passée de 8,5 secondes à 4,9 secondes, ce qui représente une nette amélioration. Notre TTI se produit toujours à environ 8,5 secondes, ce qui n'a donc pas été grandement affecté par ce changement. Nous avons ici un changement perceptuel. Certains pensent même qu'il s'agit d'un tour de passe-passe. En affichant un visuel intermédiaire du jeu, nous améliorons les performances de chargement perçues.

Encastrables

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

La section claire d'une requête signifie que celle-ci attend de recevoir le premier octet de la réponse.

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

C'est ce problème pour lequel HTTP/2 Push a été conçu à l'origine. Le développeur de l'application sait que certaines ressources sont nécessaires et peut les mettre en ligne. Lorsque le client se rend compte qu'il doit récupérer 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 à comprendre et est considéré comme déconseillé. Cet espace de problème 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.

Grâce à l'intégration de JavaScript, nous avons réduit notre TTI de 8,5 s à 7,2 s.

Cela nous a permis de réduire de 1 seconde notre TTI. Nous avons atteint le point où index.html contient tout ce qui est nécessaire pour l'affichage initial et pour devenir interactif. Le code HTML peut s'afficher pendant le téléchargement, créant ainsi notre FMP. Une fois le code HTML analysé et exécuté, l'application devient interactive.

Fractionnement agressif du code

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. Revenons sur ce avec quoi l'utilisateur peut interagir au début: nous disposons d'un formulaire permettant de configurer le jeu, qui contient deux 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 semble faire beaucoup.

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

Pour comprendre d'où provient la taille du bundle, nous pouvons utiliser un explorateur de carte source ou un outil similaire afin de décomposer le groupe. Comme prévu, notre lot contient la logique de jeu, le moteur de rendu, l'écran gagnant, l'écran perdu et un grand nombre d'utilitaires. Seul un petit sous-ensemble de ces modules est nécessaire pour la page de destination. Si vous déplacez tous les éléments qui ne sont pas strictement nécessaires à l'interactivité dans un module à chargement différé, vous réduirez considérablement l'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 donc procéder à une division du code. La division du code divise votre bundle monolithique en plusieurs parties plus petites pouvant être chargées à la demande de manière différée. Les bundlers populaires tels que Webpack, Rollup et Parcel acceptent le fractionnement de code à l'aide d'un import() dynamique. Le bundler analyse votre code et intégré tous les modules qui sont importés de manière statique. Tout ce que vous importez de manière dynamique est placé dans son propre fichier et extrait du réseau une fois l'appel import() exécuté. Bien sûr, atteindre le réseau a un coût et ne doit être fait que si vous avez du temps à perdre. La devise est d'importer de manière statique les modules dont vous avez essentiellement besoin au moment du chargement et de charger dynamiquement tout le reste. Toutefois, n'attendez pas le tout dernier moment pour charger en différé les modules qui vont être utilisés. La méthode "Inactif jusqu'à urgence" de Phil Walton constitue un bon compromis entre le chargement différé et le chargement hâtif.

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 directement les composants à chargement différé. 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 cela en place, nous pouvons utiliser la 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 élément <div> vide pendant le 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 que tout cela est 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 sur la FMP et le TTI ? WebPageTest vous indiquera !

Confirmation de la vidéo filmée: notre TTI est maintenant de 5,4 s. C'est une nette amélioration par rapport aux 11 initiales.

Notre FMP et le TTI ne sont espacés que de 100 ms, car il ne s'agit que d'analyser et d'exécuter le code JavaScript intégré. Après seulement 5,4 s en 2G, l'application est complètement 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 sûr, le jeu ne peut pas démarrer tant que notre moteur de rendu n'est pas disponible. Nous pourrions désactiver le bouton "Start" (Démarrer) jusqu'à ce que notre moteur de rendu soit prêt à lancer le jeu, mais d'après notre expérience, l'utilisateur a généralement besoin de suffisamment de temps pour configurer les paramètres de son jeu pour que cela ne soit pas nécessaire. La plupart du temps, le chargement du moteur de rendu et des autres modules restants est terminé lorsque l'utilisateur appuie sur "Start" (Démarrer). Dans les rares cas où l'utilisateur est plus rapide que sa connexion réseau, nous affichons un simple écran de chargement qui attend la fin des modules restants.

Conclusion

Il est important de mesurer les performances. Pour éviter de perdre du temps sur des problèmes irréels, nous vous recommandons de toujours commencer par mesurer les performances avant d'implémenter les optimisations. En outre, les mesures doivent être effectuées sur des appareils réels avec une connexion 3G ou sur WebPageTest en l'absence d'appareil réel.

La pellicule peut vous donner des informations sur le sentiment du chargement de votre application pour l'utilisateur. La cascade d'annonces peut vous indiquer quelles ressources sont à l'origine de temps de chargement potentiellement longs. Voici une liste de mesures à prendre pour améliorer les performances de chargement:

  • Diffusez autant d'éléments que possible via une seule connexion.
  • Précharger ou même les ressources intégrées nécessaires pour le premier rendu et l'interactivité.
  • Préchargez votre application pour améliorer ses performances de chargement.
  • Utilisez une fraction de code agressive pour réduire la quantité de code nécessaire à l'interactivité.

Ne manquez pas la deuxième partie, qui explique comment optimiser les performances d'exécution sur les appareils soumis à des contraintes extrêmes.