journal

Optimiser le frontend (2) : comprendre le navigateur, de la requête à l’affichage

Dans cet article j’essaye de comprendre et d’expliquer, en m’appuyant sur de nombreuses sources, le fonctionnement global du navigateur lorsqu’un utilisateur requiert une page web en tapant son nom dans une barre d’adresse. Il se fonde en grande partie sur le travail effectué par Tali Garsiel sur son site puis partagé par la suite par Paul Irish.

Le navigateur est bien évidemment l’outil qui permet de parcourir différents sites web : il affiche dans une fenêtre une ressource web (qui n’est donc pas essentiellement une page web mais peut-être aussi simplement un PDF ou une image par exemple) qu’il a au préalable demandé à un serveur. Comme tel, le navigateur est aussi l’outil de base de tout développeur front-end qui, en comprenant son fonctionnement, peut prendre des décisions éclairées et pertinentes et comprendre la raison au fondement des pratiques recommandés pour le développement.

Selon StatCounter statistics (décembre 2015), les navigateurs les plus utilisés sont (dans l’ordre) Chrome, Internet Explorer, Firefox et Safari, qui représentent à eux quatre plus de 90% des usages sur desktop (plus de la moitié pour Chrome), et Chrome, UC Browser et Safari qui représentent à eux trois 75% des usages sur mobile. Malgré leurs différences, la structure de ces navigateurs est globalement la même et est schématisée ainsi dans l’article de Paul Irish :

schéma d'un navigateur Schéma d’un navigateur

Un navigateur se compose donc des éléments suivant :

  1. Une user interface (UI) : le navigateur ne comprend pas uniquement la fenêtre de rendu de la page mais également un ensemble de composants d’interface permettant de faire fonctionner le navigateur (comme la barre d’adresse par exemple) et de proposer une bonne expérience de “navigation” (navigation facilitée par les boutons de retour, possibilité de rafraîchir la page, d’ajouter des pages au favoris, de naviguer dans un historique des pages consultées, …), voire même de développement (console, outils d’analyse des pages, outils d’émulation, …). Ce type d’éléments d’interface, donnant des informations et des commandes à l’utilisateur pour interagir avec le contenu, est parfois appelé le chrome (Mozilla propose d’ailleurs un projet de navigateur “chromeless” dans lequel le chrome serait entièrement customisable).

  2. Un browser engine et un rendering engine (moteur de rendu) ne sont pas toujours distingués dans la mesure où le premier permet de manipuler le second, qui lui permet d’interpréter les différents langages du web et d’afficher les différents éléments de la page web à l’écran.

  3. Un système de networking qui permet de faire appel aux ressources extérieures (les appels réseau comme les requêtes HTTP par exemple).

  4. Un UI backend : l’interface qui permet d’afficher certains composants comme les listes déroulantes et les fenêtres pop-up.

  5. Un JavaScript engine utilisé pour analyser et exécuter le code JavaScript.

  6. Un data storage qui, comme le précise le travail de Tali Garsiel, est une “couche de persistance” qui ne disparaît pas avec la fin de la session ou la fermeture du navigateur. Le navigateur enregistre certaines données directement sur le disque dur de la machine, comme une base de donnée, utilisée notamment pour les cookies.

Pour comprendre le fonctionnement basique du navigateur, nous ne reviendrons pas sur chacun de ces éléments, mais nous nous attarderons plutôt sur une partie de l’interface utilisateur ainsi que sur le moteur de rendu afin de comprendre leur rôle dans l’affichage d’une page web. Au programme :

- La requête
	- Le protocole
	- La résolution DNS
	- La requête HTTP
- L’analyse des ressources
	- Le moteur de rendu
	- Construction du DOM
	- Construction du CSSOM
	- Construction du render tree
- La mise en page
	- Le calcul des composants : le layout
	- L’affichage de la page : le painting

La requête

Le premier élément intéressant de l’interface utilisateur du navigateur est sa barre d’adresse.

En mettant de côté le mécanisme d’autocomplétion, lorsque l’on entre une URL (Uniform Resource Locator) dans le navigateur et que l’on appuie sur la touche “Entrée”, une séquence de différents mécanismes s’enchaîne.

Le protocole

Le navigateur analyse l’URL et regarde le protocole (dans le cas du web : HTTP pour “Hyper Text Transfer Protocol”, ou HTTPS dans sa version “Secure”), le nom de domaine ainsi que la ressource demandée.

Anatomie d'une URL Anatomie d’une URL

Il vérifie si le site n’est pas répertorié dans sa liste préchargée de sites utilisant le mécanisme de sécurité HSTS (HTTP Strict Transport Security). Si c’est le cas le navigateur remplace automatiquement tous les liens non sécurisées (HTTP) par des liens sécurisés (HTTPS) et bloque l’accès du site si la connexion ne peut être sécurisée.

La résolution DNS

Le navigateur cherche ensuite à “résoudre le nom de domaine” (on parle de résolution DNS, pour Domain Name System), c’est-à-dire à trouver l’adresse IP associée au nom de domaine. Toutes les machines (comme les serveurs par exemple) connectées à un réseau IP (Internet Protocol) comme Internet possèdent une adresse IP permettant de l’identifier. Le nom de domaine correspondant n’est d’ailleurs qu’un nom plus simple à retenir pour l’utilisateur. Résoudre le nom de domaine d’une requête permet donc d’identifier sur quel serveur se trouve les ressources demandées.

Afin d’optimiser le processus de résolution du DNS, le navigateur interroge d’abord différents caches afin de savoir s’ils connaissent déjà l’adresse IP recherchée. Il s’adresse ainsi tour à tour (tant qu’il n’a pas trouvé la réponse) à son propre cache, au cache du système d’exploitation de la machine, au cache du routeur, et au cache du FAI.

S’il n’a toujours pas pu résoudre le nom de domaine, le navigateur interroge alors un ou plusieurs serveurs DNS “récursifs” qui l’aideront dans cette petite chasse à l’IP. Ces derniers serveurs interrogent les serveurs racines qui lui indiquent quels serveurs de premier niveau aller voir. Ces serveurs de premier niveau indiquent quand à eux quels sont les serveurs correspondant à la zone associée au nom de domaine (.org, .com, .net, etc.). Le serveur DNS récursif interroge alors tour à tour ces derniers serveurs avant d’obtenir l’IP qu’il lui faut.

résolution du nom par un serveur DNS récursif La page Wikipedia des DNS propose ce schéma pour clarifier un peu le processus de résolution du nom par un serveur DNS récursif. Le navigateur interroge le serveur récursif (1) qui interroge le serveur racine (2). Ce dernier indique les serveurs correspondant à la zone .org que le serveur récursif s’empresse d’interroger (3 et 4). Il continue ainsi jusqu’à ce qu’un serveur lui réponde favorablement (5, 6, 7) afin qu’il puisse renvoyer au navigateur l’IP demandée (8).

Anatomie de l'IP reçue par le navigateur Anatomie de l’IP reçue par le navigateur

La requête HTTP

Lorsque le navigateur possède son adresse IP, il peut alors ouvrir une connexion TCP avec le serveur en question, en échangeant quelques informations avec lui (pour le résumer grossièrement), et commencer à lui demander les ressources de la page par une requête HTTP. On peut trouver à quoi ressemblent ces requêtes dans l’onglet Network des outils de développements du navigateur. Voici par exemple une requête GET de Chrome vers la page d’accueil de ce site :

GET / HTTP/1.1
Host: thibault.mahe.io
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36
DNT: 1
Accept-Encoding: gzip, deflate, sdch
Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.6,en;q=0.4
Cookie: __utma=220424157.636640516.14423267

Dans cette requête, le navigateur se présente (User-Agent), indique notamment l’URL de la ressource qu’il souhaite (notamment avec Host) ainsi que les type de ressources qu’il accepte de recevoir (Accept et Accept-Encoding). Il demande également au serveur de garder ouverte la connexion pour les requêtes de ressources à venir (Connection) et communique les éventuels cookies qu’il a pu enregistrer lors de navigations antérieures (cookie).

Le serveur reçoit cette requête :

Le navigateur a enfin la ressource HTML demandée ! Il commence donc à la parser et détecter les ressources connexes à récupérer. Il reprend alors le même processus pour chacune des ressources (CSS, images, scripts, …), réinterrogeant le serveur si les ressources sont relatives, ou retournant à l’étape de résolution DNS si les ressources sont référencées sur un nom de domaine différent.

composition d'une requête HTTP Composition d’une requête HTTP (aperçu depuis WepPageTest) : on y retrouve l’étape de résolution DNS, puis la connexion TCP au serveur, puis la requête HTTP en elle-même (le Time to First Byte correspondra ici au temps pris par le serveur pour trouver la ressource souhaitée, entre la première connexion et le moment où le serveur commence à envoyer la ressource) et enfin l’envoi du contenu.

Requêtes d'une page web Une page web se construit donc après plusieurs requêtes, nécessitant parfois une nouvelle résolution DNS ou une nouvelle connexion TCP au serveur.

Notons enfin que sur ce processus, le navigateur est tributaire du serveur, mais que des optimisations sont possibles côté front-end pour améliorer la relation client - serveur. Cette relation repose généralement sur deux indicateurs :

L’optimisation de ces deux indicateurs fait parfois débat car les manières de les améliorer côté front-end peuvent paraître contradictoires : pour limiter les temps de latence, l’idéal est de réduire le nombre de requêtes à faire au serveur, et donc de concaténer ensemble certaines requêtes ; à l’inverse, pour limiter l’impact de la bande passante, l’idéal semble être de limiter la taille des contenus requêtés, quitte à diviser certaines ressources en plusieurs requêtes.

Sur ce sujet, il est intéressant de lire le travail d’Ilya Grigorik ainsi que cette présentation de Paul Irish sur ce débat : si l’optimisation côté navigateur dépend en grande partie des ressources de la page web (une vidéo Youtube est par exemple plutôt limitée par la bande passante, alors que naviguer à travers plusieurs pages d’un site est plutôt limité par le temps de latence), il semblerait qu’optimiser le temps de latence apporte des améliorations plus rapidement perceptibles pour les utilisateurs…


Des liens sur la relation client - serveur :


L’analyse des ressources

Le moteur de rendu

À ce stade, le navigateur commence progressivement à obtenir toutes les ressources nécessaires à l’affichage de la page. Son travail va donc être de transformer ces fichiers codés en différents langages en une page web lisible et compréhensible par les utilisateurs. Comme nous le disions en introduction, le responsable de l’affichage de la page web, au coeur du navigateur, est le moteur de rendu.

Plusieurs moteurs de rendu existent. Firefox utilise par exemple Gecko, Safari, Chrome et Opera utilisent Webkit (ou un dérivé : Blink), Internet Explorer utilise Trident. Leurs processus pour afficher une page web peuvent légèrement différer les uns des autres, mais il s’agit généralement d’une même séquence d’évènements, pouvant se schématiser ainsi :

Processus global du moteur de rendu Processus global du moteur de rendu : les terminologies utilisées ici sont surtout celles de Webkit. Gecko utilise par exemple plutôt le terme “Frame tree” pour le Render Tree, considérant chaque composant comme une frame, et le terme “Reflow” pour le Layout. (source)

Comme le montre ce schéma, le processus de rendu d’une page, à partir de la réception des ressources envoyées par le serveur, se compose globalement de 4 “grandes” étapes :

Soulignons bien que ce processus se fait progressivement, car le navigateur ne reçoit pas toutes les ressources nécessaires en même temps. Certaines ressources sont en effet “bloquantes”, c’est-à-dire que le processus de rendu ne peut continuer tant que ces ressources n’ont pas été prises en compte. Comme le montre le précédent schéma, la construction du DOM et du CSSOM bloque par exemple logiquement la construction du render tree et donc de la page ; l’analyse du JS bloque également la construction du DOM et du CSSOM car le JavaScript peut les modifier dynamiquement (en manipulant des éléments, en en ajoutant des nouveaux ou encore par exemple en ajoutant des propriétés).

Si une ressource est bloquante tant qu’elle n’est pas analysée ou exécutée, le thread principal utilisé par le moteur de rendu reste effectivement bloqué, mais en revanche celui-ci peut faire appelle à un autre thread qui, en parallèle, peut continuer de parcourir le reste du document et charger les ressources non-bloquantes qu’il rencontre, si celles-ci ne modifient pas le DOM. Cette optimisation, mise en place par Gecko et Webkit, est appelée “speculative parsing” et explique notamment pourquoi, dans le schéma des requêtes HTTP visibles dans l’onglet Networkdes outils développeurs (voir l’aperçu plus haut), certaines ressources semblent demandées et prises en charge en parallèle.

On parle de “chemin critique de rendu” pour qualifier la prise en compte par le navigateur des ressources nécessaires (donc “critiques”) au premier affichage de la page. Dans la mesure où toutes les ressources d’un site web ne sont pas forcément nécessaires pour l’affichage de la première portion de page directement visible par l’utilisateur, il est important que le développeur front-end optimise ce chemin critique du rendu en limitant les ressources critiques et en différant la prise en compte des ressources non-critiques.

Schéma du chemin critique de rendu Schéma du chemin critique de rendu : certaines ressources sont bloquantes pour l’affichage de la page

Le processus d’affichage de la page par le navigateur commence donc par la construction du DOM et du CSSOM.

Construction du DOM

La construction du DOM à partir des données envoyées par le serveur passe par 4 processus de transformation :

Construction du DOM Schéma de la construction du DOM (source).

À noter par ailleurs que l’algorithme de parsing du HTML diffère quelque peu du processus de parsing général (conversion de bytes en caractères, identification des tokens, conversion des tokens en noeuds, construction de l’arborescence) :

processus de parsing HTML Le parser HTML est “réentrant” car pendant le processus même de nouveaux tokens peuvent être ajoutés dynamiquement.

Construction du CSSOM

Lorsque le navigateur commence à parser le HTML pour construire le DOM, il rencontre dans la plupart des cas dans le header une balise style définissant le rendu de la page ou un lien externe vers une feuille de style. Dans ce dernier cas, connaissant son importance pour le rendu de la page, il envoie directement une requête au serveur pour récupérer la ressource.

À ce stade, le processus est sensiblement le même pour le parsing CSS que pour le parsing HTML : les bytes sont convertis en caractères, puis en tokens puis en noeuds, pour construire une structure appelée le “CSS Object Model” (CSSOM). À ceci près que, contrairement au HTML, le CSS possède une grammaire lexicale et syntaxique précisément définie dans les spécifications CSS du W3C.

Exemple de CSSOM Exemple de CSSOM. Le navigateur construit forcément un CSSOM pour chaque page car il fournit par défaut ses propres styles CSS (source).

La structure du style en arborescence permet au navigateur de gérer la dimension “cascade” du CSS : il commence ainsi par prendre en compte les règles les plus globales pour procéder ensuite aux règles plus spécifiques en suivant les différents chemins du CSSOM. Chaque noeud hérite ainsi des propriétés du noeud parent. Les règles de spécificté du CSS seront quant à elles prises en compte lors de la construction du render tree, étape au cours de laquelle DOM et CSSOM se rencontrent…

Construction du render tree

Le render tree est donc une arborescence fusionnant le DOM et le CSSOM et définissant quels éléments apparaîtront dans la page, et dans quel ordre. En connaissant cet ordre, le navigateur pourra ensuite afficher correctement la page.

Chaque composant de la page, qualifié de frame par Gecko et de render object par Webkit, est considéré comme une boîte, une zone rectangulaire possédant des propriétés (height, width, border, margin, …) définies par le modèle de boîte CSS. Ces boîtes sont donc également affectées par la valeur de la propriété display du composant : des composants inline, block, inline block, list item, … ne sont pas rendus de la même manière. De même, les composants ayant une propriété display: none ne sont pas rendus dans le render tree, tout comme les composants du DOM non visuels comme le head ou un script. Les composants qui ne s’inscrivent pas dans le flux “naturel” de la page, comme les éléments ayant une propriété float définie ou un positionnement fixed ou absolute sont quant à eux placés à un endroit particulier du render tree.

Un des intérêts du render tree est que pour définir les render objects il doit nécessairement calculer les propriétés visuelles de chaque élément. Pour procéder à cela, le navigateur est confronté à 2 soucis majeurs auxquels il apporte autant de solutions :

rule a b c d specificity
* 0 0 0 0 0
p 0 0 0 1 1
p:last-child 0 0 0 2 2
ul li 0 0 0 2 2
p + p 0 0 0 2 2
form 0 1 0 0 100
.red 0 0 1 0 10
p.red.large 0 0 2 1 21
style=”color:black” 1 0 0 0 1000

Exemple de tableau de comparaison de spécificité de sélecteurs CSS (source)

Une fois les règles de cascade et de spécificité appliquées à chacune des correspondances DOM - CSSOM, le render tree est enfin construit correctement, constitué des contenus et des informations exhaustives sur les styles de tous les éléments visibles de la page, n’attendant plus qu’à être affichés à l’écran. Mais en passant d’abord par l’étape de dimensionnement…


Des liens sur les moteurs de rendu et la construction du render tree :


La mise en page

Le calcul des composants : le layout

Le noeud à la racine (root node) du render tree est l’élément contenant tous les autres éléments. Gecko l’appelle ViewPortFrame, Webkit l’appelle RenderView, et il correspond basiquement à la zone du viewport commençant au début de l’écran (0, 0) jusqu’à son extrémité opposée (window.innerWidth, window.innerHeight). Pour savoir quels éléments afficher et où les afficher le navigateur parcourt le render tree.

Comme on le précisait plus tôt, quand un élément est ajouté au render tree, il n’a encore ni position ni taille, car ces propriétés doivent être calculées lors de l’étape de layout. Le processus de layout s’applique d’abord au RenderView, puis s’applique de manière récursive à chacun des éléments du render tree qui le nécessite, en suivant l’arborescence  :

Cette dernière indication permet en effet au navigateur de distinguer les éléments sur lesquels un layout doit être effectué (ceux avec un dirty bit) et ceux qui n’en nécessitent pas ou plus. Avec ce système, le navigateur n’a pas besoin de réappliquer un layout sur tout le render tree à chaque micro changement. En fait, le navigateur ne fait un layout global que dans certains cas précis : au chargement de la page, à son redimensionnement ou encore lorsqu’un élément important pour le dimensionnement est modifié, comme les fontes par exemple. Et encore, le layout est bien souvent, dans ces cas spécifiques, facilité par le fait que les dimensions des éléments sont déjà stockés en cache. Dans tous les autres cas, le processus de layout est incrémental et ne concerne que les éléments marqués par un dirty bit.

Le processus de layout décrit plus haut est le mécanisme le plus courant . Dans ce déroulement, le système de layout effectue 2 “passes” (ou layout pass) : une pour mesurer (par exemple la taille des éléments enfants), l’autre pour réarranger (par exemple la taille de l’élément parent). Pour autant, le HTML utilise de base un modèle de mise en page fondé sur un flux, faisant en sorte que les composants se positionnent naturellement les uns au dessus des autres, sans influencer leurs dimensions respectives, et donc ne nécessitant qu’une seule passe.

Dans ce dernier cas “naturel”, le processus de layout est assez simple et rapide. À l’inverse, le processus devient plus complexe et demande plus de temps de calcul lorsque les éléments sont sortis du flux, avec les différentes valeurs de float ou avec les positions absolute ou encore fixed. Il est également d’autant plus complexe lorsque les éléments sont imbriqués et donc interdépendants, comme c’est le cas le plus souvent. Pour ces deux raisons notamment, le processus de layout peut être un frein non négligeable à l’optimisation du temps d’affichage de la page, et une recommandation souvent avancée est de toujours éviter de déclencher des nouveaux layout (en modifiant dynamiquement certaines propriétés CSS par exemple). Paul Lewis, spécialiste des performances web à Google, recommande par ailleurs, pour les cas de positionnement spécifique (hors du flux) de préférer le positionnement avec Flexbox plutôt qu’avec float car Flexbox nécessite moins de passes du layout pour être calculé.

En 2007, Satoshi Ueyama proposait de voir à quoi ressemblait le processus de reflow (layout) du site de mozilla de manière ralentie sous Firefox. Une autre version de la vidéo a également été réalisée en 2009.

L’affichage de la page : le painting

Une fois les dimensions des éléments calculées, le navigateur dispose de toutes les informations nécessaires pour afficher ces éléments à l’écran : c’est l’étape finale de painting où la méthode paint() est appelée pour transformer chacun des noeuds parcourus du render tree en pixels s’affichant à l’écran.

Cette étape est assez similaire dans ses mécanismes avec l’étape précédente de layout en ce sens qu’elle peut être aussi globale (quand tout le render tree a été modifié par un layout) ou incrémentale (si une portion seulement de l’arborescence est à re-peindre).

Les éléments ne sont pas affichés en une seule traite mais selon un ordre déterminé, défini dans les spécifications CSS, et suivant l’empilement de différentes couches ou stacks (on parle alors de stacking context) correspondant à la propriété z-index de l’élément :

stack du paiting order La stack définissant le painting order : entre le canvas (ligne verticale tout à gauche) et l’utilisateur (tout à droite) peut se trouver une multitude de couches au z-index différent (source).

Les différentes stacks de chaque composant de la page sont donc empilées les unes au-dessus des autres, en commençant bien entendu par la couche la plus éloignée de l’utilisateur, généralement d’abord le canvas, peint en blanc (couleur définie par l’user-agent). L’ordre de priorité de prise en compte des propriétés est le suivant :

À chaque “rencontre” dans le render tree d’un ou plusieurs éléments enfants, la priorité est donnée en fonction du z-indexde l’élément, mais également de son ordre dans l’arborescence ainsi que son positionnement (les éléments sortis du flux sont affichés en dernier).

Une fois cet ordre respecté, réitéré sur tout le render tree et une fois les règles de style CSS mises en oeuvres, la page est prête à être affichée. L’ensemble de ces informations est envoyé à un logiciel interne de “rastérisation” (Chrome et Firefox utilisent tout deux Skia) qui transforme ces données en différents bitmaps. Ces derniers sont ensuite envoyés au GPU qui les rassemble et compose l’image finale à l’écran. La page s’affiche enfin !

Le processus, parfois à peine perceptible, reste assez long et nécessite bien souvent pour le front-end designer de bien le connaître et de l’optimiser au possible afin d’assurer à l’utilisateur une expérience optimale lors de son utilisation d’un outil que lui connaît bien souvent mal, le navigateur.


Des liens sur la mise en page :