Nel capitolo precedente il browser è comparso come uno dei due estremi dell'architettura Web di base: un client HTTP che chiede risorse e le mostra. Quella descrizione era corretta ma ormai riduttiva. Ad oggi i browser possono essere considerati delle vere e proprie suite di software, che offrono molte più funzionalità di un semplice visualizzatore: sono in grado di interpretare e visualizzare pagine Web HTML, script JavaScript, AJAX, applicazioni e in generale gli altri contenuti che possono essere ospitati sui server Web. Molti browser offrono inoltre plugin che ne estendono capacità e caratteristiche, consentendo di navigare e interagire con contenuti multimediali ed eseguire task specifici: videoconferenze, filtri anti-phishing, strumenti per progettare pagine Web e così via.
C'è una domanda che precede tutte le altre, ed è la domanda che regge questo capitolo: chi decide come un file HTML viene interpretato e visualizzato? La risposta è che il modo in cui il browser interpreta e visualizza i file HTML è specificato negli standard HTML e CSS, rilasciati e mantenuti dal W3C. Il browser, in altre parole, non è libero: è l'implementazione di una specifica pubblica. Tutto quello che vedremo nelle prossime pagine — l'algoritmo di parsing, l'ordine della cascata, l'ordine di painting — è scritto in un documento normativo, non deciso caso per caso dal produttore.
Storicamente, però, questa disciplina è arrivata tardi. Per molti anni i browser sono stati conformi a solo una parte delle specifiche e hanno sviluppato le loro proprie estensioni: è esattamente questo che ha scatenato le guerre dei browser raccontate nel capitolo 1, portando a incompatibilità significative e a una notevole difficoltà da parte degli sviluppatori Web, costretti a scrivere e mantenere varianti dello stesso codice. Ad oggi la maggior parte dei browser ha incrementato il livello di conformità rispetto agli standard del W3C, ed è questa convergenza che rende sensato studiare "il" funzionamento del browser al singolare.
La situazione attuale vede il predominio incontrastato di Chrome e la dismissione annunciata di Internet Explorer, che pure era stato il vincitore della prima guerra dei browser. Le date citate a lezione sono precise e vale la pena ricordarle, perché segnano la chiusura di un'epoca:
Il vincitore della prima guerra dei browser è stato dismesso dalla stessa azienda che lo aveva imposto. È la conferma pratica di quanto detto nel capitolo 1: il vantaggio competitivo costruito sulle estensioni proprietarie non è durevole, mentre la conformità agli standard è la condizione che permette a un browser di restare utilizzabile nel tempo. Anche Microsoft Edge, dal 2019, ha abbandonato il proprio motore di rendering per adottarne uno condiviso.
Un browser può essere visto come un gruppo di software con codice strutturato che insieme eseguono diversi task allo scopo di visualizzare una pagina web nella sua finestra. Questi diversi software sono ciò che chiamiamo le componenti del browser, e sono sette. Impararle a memoria non basta: quello che conta è capire chi parla con chi, perché la struttura delle dipendenze spiega quasi tutti i comportamenti osservabili di un browser reale.
| # | Componente | Responsabilità |
|---|---|---|
| 1 | Interfaccia utente (UI) | Tutti gli elementi e le funzionalità a corredo del browser: barra degli indirizzi, bottoni Next e Back, menu per il bookmark. In pratica tutte le parti della finestra con cui possiamo interagire, tranne la finestra di visualizzazione in cui viene renderizzata la pagina HTML |
| 2 | Browser engine | Fa da "ponte" tra l'interfaccia utente e il motore di rendering delle pagine HTML |
| 3 | Rendering engine | Visualizzazione della risorsa richiesta: parsing e rendering di HTML e CSS |
| 4 | Networking (client HTTP) | Invio e ricezione delle richieste HTTP |
| 5 | Interprete JavaScript | Parsing e interpretazione del codice JavaScript |
| 6 | UI backend | Rendering e disegno di elementi grafici di base (finestre, bottoni, combo box); espone elementi di una interfaccia generica non dipendente da una specifica piattaforma, basandosi sull'interfaccia del sistema operativo |
| 7 | Data persistence | Ospita e gestisce i dati e le informazioni che il browser deve salvare localmente, per esempio i cookie; altri meccanismi di storage supportati sono localStorage, WebSQL, IndexedDB, FileSystem |
Le sette componenti non stanno su un piano orizzontale: sono impilate. L'interfaccia utente sta in cima, il browser engine subito sotto come intermediario, il motore di rendering sotto ancora; il motore di rendering a sua volta si appoggia a tre servizi — networking, interprete JavaScript e UI backend — che stanno alla base. La data persistence è disegnata a lato, perché non appartiene a un livello soltanto: è un servizio trasversale che tutti gli altri possono usare.
Le slide dichiarano esplicitamente quali componenti verranno approfondite: il rendering engine, di cui si confronteranno funzionamento, caratteristiche e differenze tra Gecko (Firefox) e WebKit (Safari), e l'interprete JavaScript. È una scelta ragionata: sono le due componenti che determinano cosa un'applicazione Web può fare e quanto velocemente lo fa, ed è su di esse che poggiano tutte le tecnologie viste nel resto del corso.
L'interfaccia utente del browser è composta dagli elementi attraverso i quali l'utente interagisce con il browser. Comunica con gli altri componenti per mostrare il contenuto delle risorse Web e interagisce anche con il sistema operativo. Il punto interessante, e forse controintuitivo, è che non esistono standard o specifiche che definiscano in modo formale il look&feel e l'organizzazione dell'interfaccia utente del browser. Quello che vedete quando aprite Chrome, Firefox o Safari non è normato da nessuna parte: deriva da best practice raccolte nel corso degli anni e che i browser hanno imitato l'uno dall'altro, riportando elementi simili per funzionamento e aspetto a quelli delle interfacce di altre applicazioni.
Lo standard HTML5 non definisce quali elementi GUI i browser debbano necessariamente avere, ma ne elenca alcuni comuni (la sezione di riferimento è w3.org/TR/2014/REC-html5-20141028/browsers.html). Tra gli altri, include: la barra degli indirizzi (URI), i bottoni di Back e Next, il bottone di Home page, i bottoni di Refresh e Stop, gli elementi per la gestione del bookmark, i tab, e in generale ogni altro elemento al di fuori della finestra di rendering, cioè del viewport.
Vale la pena fissare la definizione per sottrazione, perché è quella che ricorre nelle domande d'esame: l'interfaccia utente è tutto ciò che sta fuori dal viewport. Dentro il viewport non comanda più il browser, comanda il documento: lì il disegno è deciso dal motore di rendering a partire da HTML e CSS. È un confine netto, ed è anche il confine tra ciò che è standardizzato (il contenuto) e ciò che non lo è (la cornice).
Il motore del browser funziona come una sorta di ponte tra l'interfaccia utente e il motore di rendering. Sulla base degli input ricevuti attraverso l'interfaccia utente, si occupa di effettuare le richieste al motore di rendering. Concretamente questo significa tre cose. Primo, si occupa del caricamento dell'URL e supporta le primitive relative alle principali azioni di browsing: Back, Next, Reload. Secondo, gestisce vari aspetti della sessione di navigazione, come la visualizzazione dei progressi nel caricamento della pagina corrente. Terzo, si occupa degli alert relativi all'esecuzione di script JavaScript.
Quando premete il tasto Back non state parlando con il motore di rendering: state parlando con l'interfaccia utente, che passa l'input al browser engine, che a sua volta traduce quel gesto in una richiesta al motore di rendering. Il browser engine è la componente che rende l'interfaccia indipendente dal motore di rendering sottostante — ed è anche il motivo per cui un browser può cambiare motore di rendering, come è accaduto a Edge nel 2019, senza che l'utente se ne accorga guardando la finestra.
La componente di networking si occupa di recuperare gli URL richiesti dall'utente attraverso l'interfaccia, utilizzando i principali protocolli di comunicazione — HTTP, ma anche FTP. In particolare è in grado di inviare una richiesta HTTP di una risorsa, sulla base del suo URL, a un server Web, e di ricevere dal server la risorsa richiesta, che è ciò che permette di alimentare il motore di rendering.
Notate la doppia origine delle richieste, perché è un dettaglio che si dimentica facilmente: la richiesta può arrivare attraverso l'interfaccia utente (avete digitato un URL nella barra degli indirizzi) oppure attraverso la risorsa attualmente renderizzata nel viewport (la pagina contiene un <img>, un foglio di stile, uno script, oppure il codice JavaScript esegue una chiamata). Il networking gestisce inoltre tutti gli aspetti di comunicazione attraverso la rete Internet, inclusi quelli di sicurezza.
Il networking potrebbe rappresentare un bottleneck delle performance del browser durante la navigazione, dato che deve necessariamente restare in attesa delle risorse che arrivano dai server per comporre la pagina Web. È il collo di bottiglia strutturale dell'intera architettura: per quanto veloci siano il parser, il layout e il compilatore JIT, nessuno di loro può lavorare su byte che non sono ancora arrivati. Tenete a mente questa frase quando, nei capitoli sulla sostenibilità e sulle prestazioni, si parlerà di ridurre il numero di richieste: non è un consiglio estetico, è un intervento sull'unico punto della catena che il codice applicativo non può accelerare.
Proprio perché la rete è il punto lento, il networking può implementare una cache dei documenti recuperati, per ridurre il traffico sulla rete. Il meccanismo, come descritto a lezione, è questo: il browser inizialmente controlla se sono presenti cache di dati e risorse nella memoria locale per ogni URL richiesto; se non trova nulla, allora viene creata una richiesta HTTP con un domain name per richiedere la risorsa attraverso la rete Internet. La cache non è quindi un'ottimizzazione applicata dopo, è il primo passo di ogni recupero di risorsa.
C'è poi un secondo livello, che dipende dal sito e non dal browser: se il sito web richiesto implementa una cache, allora viene fatta una copia dei dati in AppCache o Service Workers, per l'accesso successivo. Il beneficio dichiarato è duplice — risposte rapide e risparmio di tempo per accessi regolari e ripetuti. Riprenderemo AppCache e i Service Workers nella sezione sulla data persistence, perché sono al tempo stesso meccanismi di rete e meccanismi di storage: è questa doppia natura che li rende il fondamento delle Progressive Web App citate nel capitolo 1.
L'UI backend è la componente più facile da confondere con l'interfaccia utente, e la distinzione va tenuta ferma. Si occupa di "disegnare", cioè di effettuare il rendering degli elementi grafici di base: i widget, i bottoni, le combo box, le finestre. Espone elementi di una interfaccia generica, basandosi sull'interfaccia del sistema operativo e sui suoi widget grafici.
La differenza rispetto alla componente numero 1 è di livello, non di argomento. L'interfaccia utente è l'insieme concreto dei controlli del browser — quella barra degli indirizzi, quei bottoni Back e Next. L'UI backend è invece la libreria che sa disegnare un bottone qualsiasi, chiedendo al sistema operativo di farlo secondo le sue convenzioni grafiche. Serve sia all'interfaccia utente del browser sia — ed è il punto che conta per noi — al motore di rendering, che nella fase finale di painting chiede all'UI backend di disegnare ogni nodo del render tree. È per questo che un <select> o un <input type="checkbox"> hanno un aspetto diverso su macOS, Windows e Linux pur essendo scritti con lo stesso identico markup: il markup arriva fino all'UI backend, e da lì in giù comanda il sistema operativo.
Questa è anche la spiegazione tecnica di una frustrazione classica dello sviluppatore front-end: certi controlli nativi si stilizzano male o per niente con il CSS. Non è un capriccio del browser, è la conseguenza del fatto che quei controlli non sono disegnati dal motore di rendering ma delegati all'UI backend, cioè al sistema operativo.
Arriviamo al cuore del capitolo. Il rendering engine si occupa del rendering della pagina web richiesta. Se si tratta di una pagina HTML, il motore di rendering deve effettuare il parsing del codice HTML, identificare e richiedere le risorse associate (CSS, immagini, video) e "disegnare" una rappresentazione grafica della pagina all'interno della finestra del contenuto, sulla base delle coordinate ricevute. Non si limita all'HTML: è in grado di effettuare il parsing e la visualizzazione di documenti XML e di immagini di diversi formati (GIF, JPEG, PNG). Si occupa infine della gestione dei link, dei form e dei dettagli ad essi correlati.
Un dato architetturale importante, e da ricordare: ogni tab del browser lancia una propria istanza del motore di rendering in un processo separato. Non è un dettaglio implementativo marginale. Significa che una pagina che manda in crash il proprio motore di rendering non porta con sé l'intero browser, e significa che il consumo di memoria di un browser cresce con il numero di schede aperte, non con il numero di finestre. È la traduzione, dentro il browser, dello stesso principio di isolamento che nei sistemi distribuiti si chiama fault containment.
| Motore | Dove viene usato |
|---|---|
| EdgeHTML | Microsoft Edge fino al 2019 |
| Gecko | Firefox, SeaMonkey, Netscape, … |
| KHTML | Konqueror |
| WebKit | iOS, Safari, Google Chrome fino alla versione 27, … |
| Blink | Google Chrome, Opera, Microsoft Edge dopo il 2019 |
Letta come una sequenza, questa tabella racconta una storia di convergenza: KHTML dà origine a WebKit, WebKit a Blink, ed Edge abbandona EdgeHTML per Blink nel 2019. Dei cinque motori elencati, oggi restano in produzione essenzialmente due famiglie — Gecko da una parte, WebKit e Blink dall'altra — ed è esattamente per questo che a lezione si confrontano Gecko e WebKit: sono i due rami rimasti dell'albero.
Il motore di rendering opera in diverse fasi, che le slide raggruppano nel cosiddetto main flow (o basic flow). È la struttura portante di tutto il resto del capitolo: le sezioni 7 e 8 stanno dentro la prima fase, la 9 è la seconda, la 10 la terza, la 11 la quarta.
Il main flow è un processo graduale. Per migliorare la user experience, il motore di rendering cerca di mostrare il contenuto nella finestra il prima possibile e non attende il completamento del parsing di tutta la pagina prima di costruire il render tree: parte del contenuto viene parsata e mostrata non appena il processo è terminato su di essa, mentre le altre parti continuano ad arrivare dal client HTTP e vengono parsate e mostrate a loro volta. È la ragione per cui una pagina lenta appare "a pezzi" invece che tutta insieme, ed è anche la ragione per cui l'ordine in cui scrivete i tag nel documento ha conseguenze visibili sulla percezione di velocità.
Entriamo nella prima fase del main flow. Il parser HTML prende in input il markup HTML e genera un albero; il vocabolario e la sintassi dell'HTML sono definiti dallo standard W3C. Qui però compare la peculiarità che rende il parsing dell'HTML un argomento a sé, e non un esercizio di teoria dei linguaggi: HTML non può essere definito da una grammatica context-free, come invece accade per altri linguaggi quali CSS e JavaScript. HTML può essere definito in modo formale tramite DTD o XMLSchema, ma non si tratta di una grammatica context-free.
La ragione è che HTML consente una sintassi "soft", ammettendo non conformità: certi tag possono essere omessi, in particolare quelli di chiusura. Questo comporta un vantaggio e un prezzo. Il vantaggio è la facilità di utilizzo da parte degli sviluppatori; il prezzo è la difficoltà nel definire una grammatica formale e nel realizzare un parser convenzionale, proprio perché non si tratta di una grammatica context-free.
Ne segue che HTML non può essere parsato utilizzando algoritmi di parsing tradizionali, cioè quelli usati per le grammatiche context-free, che possono essere top-down oppure bottom-up. Le ragioni principali sono due:
Per questo i browser hanno sempre adottato dei parser customizzati. L'algoritmo di parsing, descritto in dettaglio nella specifica HTML5, è caratterizzato da due fasi: la tokenization e la costruzione dell'albero. Le due fasi non sono sequenziali nel senso ingenuo del termine: il tokenizer riconosce un token, lo passa immediatamente al costruttore dell'albero, poi consuma il carattere successivo per riconoscere il prossimo token, e così via.
La tokenization è l'analisi lessicale, che si occupa di trasformare il codice HTML di input in token. Tra i token HTML ci sono, per esempio, i nomi dei tag di apertura e i valori degli attributi. L'output dell'algoritmo di tokenization è un token HTML, e l'algoritmo è espresso come un automa a stati finiti in cui ogni stato consuma uno o più caratteri da una stringa di input e aggiorna lo stato successivo sulla base di questi caratteri.
Le slide seguono passo passo il documento minimo che useremo per tutta questa sezione:
<html>
<body>
<p>
Hello World!
</p>
</body>
</html>
Il percorso dell'automa è questo. Lo stato iniziale è "Data state". Quando si incontra il carattere <, lo stato viene cambiato in "Tag open state". Vengono consumati i caratteri alfabetici a-z, creando uno "Start tag token" e modificando lo stato in "Tag name state", finché il carattere > viene consumato; viene fatto l'append di ogni carattere al nuovo token — nell'esempio il nome che si forma è html. Quando > viene raggiunto, il token viene emesso e lo stato torna a "Data state". I tag <body> e <p> sono trattati con gli stessi passi, fino all'emissione dei token body e p.
Poi arriva il testo. Tornati in "Data state", si procede a consumare il carattere H della foglia "Hello World", creando ed emettendo un token del carattere — un token per ogni carattere di "Hello World" — fino al carattere < di </p>. A quel punto si è di nuovo in "Tag open state", ma il carattere consumato è /, il che comporta la creazione di un token corrispondente a un tag di chiusura, con passaggio allo stato "Tag name state" fino a raggiungere >: il token viene emesso e si torna in "Data state". Lo stesso si ripete per </body> e per </html>.
Alla domanda "perché il parsing dell'HTML richiede un parser customizzato?" la risposta completa ha tre gradini e vanno detti tutti e tre. Primo: HTML non è definibile con una grammatica context-free, perché ammette una sintassi soft con tag omissibili — a differenza di CSS e JavaScript, che invece sono context-free. Secondo: i parser tradizionali (top-down e bottom-up) presuppongono proprio una grammatica context-free, quindi non sono applicabili. Terzo: si aggiungono due requisiti pratici, l'elevata tolleranza agli errori e il fatto che il sorgente sia dinamico e possa cambiare durante il parsing stesso. La soluzione della specifica HTML5 è un algoritmo in due fasi, tokenization e tree construction, entrambe espresse come automi a stati finiti.
Il parsing del codice HTML produce un albero formato da elementi e attributi DOM, il DOM tree: si tratta di una rappresentazione ad albero del documento HTML e dell'interfaccia degli elementi HTML verso il resto del mondo, per esempio verso JavaScript. La radice dell'albero è l'oggetto Document.
Ogni nodo emesso dal tokenizer sarà processato dal Tree constructor. Per ogni token viene definito quale elemento DOM è rilevante e va creato per quel token, e l'elemento viene aggiunto al DOM tree. Viene mantenuto uno stack degli elementi aperti, usato per annidare correttamente gli elementi, considerando anche i tag non chiusi. Anche questo algoritmo è espresso come un automa a stati finiti, ma i suoi stati sono chiamati insertion mode.
Seguendo lo stesso documento minimo, la sequenza degli insertion mode è la seguente. L'input della fase di costruzione dell'albero è la sequenza di token prodotta dalla tokenization, con modalità iniziale "initial mode". Viene ricevuto il token html, che implica il passaggio al "before html mode" e il reprocessing del token, provocando la creazione dell'elemento HTMLHtmlElement, del quale viene fatto l'append all'oggetto radice Document, con modifica dello stato a "before head". Il token body viene ricevuto e un elemento HTMLHeadElement viene creato anche senza il token head e aggiunto all'albero, con passaggio alla modalità "in head" e poi "after head".
Il token body viene quindi riprocessato: un elemento HTMLBodyElement viene creato e inserito nell'albero e si passa alla modalità "in body". Il token p viene processato, un elemento HTMLParagraphElement viene creato e inserito. I token per la stringa "Hello World" sono ricevuti, provocando la creazione e l'inserimento di un nodo Text, con append a questo nodo dei caratteri successivi. Una volta ricevuto il token di chiusura di body si passa alla modalità "after body", e successivamente il token di chiusura di html provoca la modalità "after after body". La ricezione del token di end of file termina il parsing.
Due dettagli dell'esempio sembrano bizzarri e invece sono la chiave di tutto. Il primo è il reprocessing: un token può essere ricevuto, provocare un cambio di insertion mode e poi essere riprocessato daccapo nel nuovo stato. Il secondo è la creazione di HTMLHeadElement pur non essendo mai arrivato un token head. Entrambi i comportamenti esistono per la stessa ragione: la specifica deve produrre un albero ben formato anche a partire da un markup che ben formato non è, e lo fa inserendo d'ufficio gli elementi mancanti nel momento in cui arriva un token che li presuppone.
I browser operano in modo tale da non restituire mai errori di tipo "Invalid Syntax" sulle pagine HTML, ma si prendono l'onere di correggere gli errori di invalidità del codice di markup, così da poter caricare comunque la pagina. Il codice di esempio delle slide è volutamente pessimo:
<html>
<mytag>
</mytag>
<div>
<p>
</div>
Pessimo codice!
</p>
</html>
Ci sono tantissimi errori: un tag inventato (<mytag>), un <p> aperto dentro un <div> e chiuso fuori, un annidamento incrociato che in XML sarebbe fatale. Il browser è in grado di mostrarlo correttamente, grazie al lavoro del parser. E c'è un fatto che sorprende sempre: la gestione degli errori è abbastanza consistente tra tutti i browser, anche se non è inclusa nella specifica dell'HTML. Esistono errori legati alla non validità del codice HTML che sono abbastanza frequenti e comuni e che vengono gestiti dai browser in modo conforme gli uni con gli altri, sulla base di anni di esperienze simili.
La tolleranza agli errori è una comodità per chi scrive, non una licenza. Il fatto che due browser correggano lo stesso errore allo stesso modo è il frutto di una convergenza de facto, non di una regola scritta: nulla ne garantisce la stabilità nel tempo o l'estensione a casi meno comuni. Scrivere markup valido resta l'unico modo per avere un comportamento specificato, e per non dipendere da un accordo tacito tra implementazioni.
Con il CSS la situazione è radicalmente più semplice, ed è utile capire perché. CSS ha una grammatica context-free — la specifica CSS definisce una grammatica sintattica e una lessicale — quindi è possibile effettuare il parsing utilizzando parser "tradizionali", quelli stessi algoritmi top-down e bottom-up che con l'HTML non erano applicabili. Non c'è tolleranza agli errori da implementare a mano, non c'è uno stack di elementi aperti da mantenere: c'è una grammatica, e un parser generato a partire da essa.
Dal parsing del CSS risulta un oggetto StyleSheet. Ogni oggetto contiene regole CSS, e ogni regola contiene il selettore e le dichiarazioni, sulla base della grammatica CSS. Questa struttura a oggetti è ciò che entra, insieme al DOM tree, nella seconda fase del main flow.
/* una regola = selettore + dichiarazioni */
p.intro > span { /* selettore */
color: #1a1a1a; /* dichiarazione: proprietà + valore */
font-family: serif; /* dichiarazione */
}
Costruire il render tree significa calcolare le proprietà e le caratteristiche che dovranno essere mostrate da ogni elemento, considerando le sue regole di stile. Le sorgenti da considerare sono più di quante si pensi: i fogli di stile esterni, interni e inline, le proprietà definite con attributi in HTML (per esempio l'attributo bgcolor, benché deprecato), il foglio di stile del browser ed eventuali proprietà di stile specificate dall'utente.
Le slide avvertono che il calcolo dello stile per ogni elemento potrebbe essere complesso, e ne elencano le cause: molteplici regole di stile; fogli di stile e regole multiple, distribuite su più file e su più livelli; selettori con struttura complessa; il meccanismo della cascata; la diversa priorità per id, classi e selettori; pseudo-classi e pseudo-selettori. È il punto del main flow in cui il lavoro cresce più che linearmente con la dimensione del progetto, ed è anche il motivo per cui i preprocessori come SCSS — che vedremo più avanti nel corso — non riducono affatto questo costo: producono comunque CSS, e il CSS prodotto va comunque risolto elemento per elemento.
Prima delle sorgenti multiple, però, valgono due regole di base. Se una proprietà (per esempio color) non è definita esplicitamente da alcuna regola, allora viene ereditata dall'elemento padre; e alcune proprietà hanno dei valori di default, per esempio background-color. Solo quando ci sono diverse definizioni provenienti da più sorgenti è necessario applicare l'ordine della cascata, che le slide elencano dal più basso al più alto:
| Priorità | Sorgente |
|---|---|
| 1 (più bassa) | Foglio di stile di default del browser |
| 2 | Preferenze dell'utente (che possono essere espresse nel browser) |
| 3 | Foglio di stile dell'autore (esterno, interno, inline + attributi HTML) |
| 4 | Foglio di stile dell'autore con !important |
| 5 (più alta) | Preferenze dell'utente con !important |
L'ordine della cascata è chiesto spesso e sbagliato spesso, perché contiene un'inversione: senza !important l'autore vince sull'utente, con !important l'utente vince sull'autore. Non è un'incoerenza, è una scelta di progetto: !important dell'utente è la valvola di sicurezza dell'accessibilità, il meccanismo che permette a chi ha bisogno di un contrasto elevato o di un font specifico di imporlo contro le decisioni dell'autore della pagina. Sapere ricostruire i cinque livelli in ordine, e saper spiegare perché l'ultimo sta in cima, è la risposta completa.
Siamo nella seconda fase del main flow. Mentre viene costruito il DOM tree, il browser costruisce un altro albero, il render tree. Notate il "mentre": i due alberi non sono in sequenza stretta, crescono insieme, coerentemente con la gradualità del processo. Il render tree è composto dagli elementi visuali nell'ordine in cui saranno visualizzati nella finestra del browser, e l'obiettivo di questo albero è agevolare il painting dei contenuti della pagina Web nell'ordine corretto.
Qui compare la prima differenza di nomenclatura tra i due motori studiati: Gecko chiama frames gli elementi del render tree, mentre WebKit li chiama renderer oppure render object. È lo stesso concetto con due nomi, e la letteratura tecnica usa entrambi.
Che cosa contiene un renderer? Ogni renderer ha le informazioni relative al suo layout e a come deve essere disegnato, sia per sé stesso che per i suoi figli. E soprattutto: ogni renderer rappresenta un'area rettangolare che solitamente corrisponde a un box CSS, e include le informazioni geometriche — width, height, position — e quelle relative al display dell'elemento specifico.
Questo è il punto più importante della sezione, e uno dei più chiesti in assoluto. I renderer corrispondono agli elementi del DOM, anche se la relazione non è necessariamente uno-a-uno. Lo scarto avviene in due direzioni opposte.
Da un lato ci sono nodi DOM che non hanno alcun renderer. Nel DOM ci sono anche elementi non visuali, che non vengono visualizzati e che quindi non fanno parte del render tree: gli esempi citati sono <head> e i metatag, e gli elementi che hanno la regola di stile display: none;. Attenzione però al confronto che segue immediatamente: gli elementi con la regola visibility: hidden; compaiono invece nel render tree. La differenza ha una spiegazione geometrica precisa — visibility: hidden occupa comunque il suo rettangolo nel layout, semplicemente non lo si disegna, mentre display: none non ha alcun rettangolo da occupare.
Dall'altro lato ci sono elementi DOM che corrispondono a più elementi visuali. Alcuni elementi hanno una struttura complessa e non possono essere descritti con un solo rettangolo. L'esempio canonico delle slide è <select>, che richiede 3 renderer: uno per l'area di testo che mostra il valore corrente, uno per il bottone a freccia, uno per la lista a discesa delle opzioni.
La domanda "che relazione c'è tra DOM tree e render tree?" ha una risposta in tre mosse. Uno: i renderer corrispondono agli elementi del DOM, ma la relazione non è necessariamente uno-a-uno. Due: ci sono nodi DOM senza renderer, cioè gli elementi non visuali come <head> e i metatag e quelli con display: none, mentre visibility: hidden resta nel render tree. Tre: ci sono nodi DOM con più renderer, perché alcune strutture complesse non sono descrivibili con un solo rettangolo, e l'esempio è <select> con i suoi 3 renderer. Aggiungete che Gecko li chiama frames e WebKit renderer, e la risposta è completa.
Terza fase del main flow. Una volta che un renderer viene creato e aggiunto all'albero, ancora non ha associate le informazioni relative alla posizione e alle dimensioni: sappiamo che quell'elemento sarà un rettangolo, non sappiamo ancora quale rettangolo. Il calcolo di questi valori è chiamato layout, o reflow — i due termini sono sinonimi e li trovate entrambi nella documentazione dei browser.
Il sistema di riferimento è semplice: le coordinate sono relative rispetto all'area di visualizzazione del browser, e l'origine degli assi è l'angolo in alto a sinistra (top, left). Coerentemente, la posizione del root renderer è 0,0 e le sue dimensioni sono quelle del viewport.
Questa fase è un processo ricorsivo: comincia con il root renderer, che corrisponde a <html>, e continua ricorsivamente, calcolando le informazioni geometriche richieste da ogni renderer. L'iter tipico è quello descritto a lezione, ed è interessante perché larghezza e altezza viaggiano in direzioni opposte:
width) del renderer è determinata da quella del suo elemento padre — scende dall'alto verso il basso;height);In altre parole: la larghezza è un'informazione ereditata dal contenitore e viaggia in discesa; l'altezza è un'informazione aggregata dai contenuti e viaggia in risalita. È per questo che dare una larghezza a un elemento è banale e dargli un'altezza sensata è spesso un problema: nella direzione verticale il browser non sa quanto spazio serve finché non ha finito di misurare i figli.
Le slide sviluppano un esempio specifico. La larghezza width del renderer è calcolata usando la larghezza dell'elemento di blocco che contiene il renderer, sulla base del valore per la proprietà width nel foglio di stile, dei margini e dei bordi. L'esempio è <div style="width: 30%"/>, e il procedimento è in tre passi:
width del contenitore è calcolata come la width della finestra del browser — esclusi bordi e scrollbar — a cui vengono sottratti il padding-left e il padding-right.<div> viene calcolata come valore della percentuale della width del contenitore.Un esempio numerico rende l'ordine dei passi meno astratto — i valori sono puramente illustrativi. Supponete una finestra da 1000 pixel con 15 pixel di scrollbar e 20 pixel di padding su ciascun lato: la width del contenitore è 1000 − 15 − 20 − 20 = 945 pixel. Il <div> al 30% misura quindi 283,5 pixel, e a questi si sommano poi i suoi bordi orizzontali e i suoi padding. Il punto da portarsi via è che la percentuale non si riferisce mai alla finestra, ma sempre al blocco contenitore: è per questo che lo stesso width: 30% produce risultati diversi a seconda di dove lo si annida.
Quarta e ultima fase. Il render tree viene visitato e navigato per poter effettivamente "disegnarne" il contenuto sullo screen. Anche qui non c'è libertà implementativa: l'ordine del processo di painting è definito dalla specifica CSS, e procede cominciando dal background e andando verso il foreground.
Per ogni renderer di blocco l'ordine è il seguente, ed è un ordine da ricordare a memoria perché è breve e viene chiesto così com'è:
| # | Cosa viene disegnato |
|---|---|
| 1 | background-color |
| 2 | background-image |
| 3 | border |
| 4 | children (i figli) |
| 5 | outline |
Osservate la posizione dei figli: stanno al quarto posto, cioè dopo sfondo e bordo del genitore e prima del suo outline. È esattamente la definizione operativa di "cominciando dal background verso il foreground": ciò che sta sotto viene disegnato per primo, e ciò che sta sopra lo copre.
Il meccanismo con cui questo avviene è descritto in modo esplicito: il browser visita il render tree e crea una lista degli elementi da disegnare per ogni rettangolo disegnato. La lista contiene i renderer rilevanti per quel rettangolo, nell'ordine corretto da seguire per disegnarli — background, bordi e così via. E, come abbiamo visto nella sezione 5, ogni nodo del render tree viene poi effettivamente disegnato sulla base del componente UI backend corrispondente: qui il motore di rendering finisce il proprio lavoro e lo consegna al sistema operativo.
A questo punto avete l'intera catena, dal byte al pixel: il networking consegna i byte, il parser HTML li trasforma in DOM tree e il parser CSS in oggetti StyleSheet, la costruzione del render tree risolve la cascata e produce i rettangoli in ordine di visualizzazione, il layout assegna coordinate e dimensioni a ciascun rettangolo, il painting li disegna dal fondo alla superficie appoggiandosi all'UI backend. Ogni volta che qualcosa cambia — uno script modifica il DOM, l'utente ridimensiona la finestra — il flusso non riparte da zero, ma riparte: ed è questo il costo nascosto delle applicazioni Web molto dinamiche.
I browser attuali includono un motore dedicato all'esecuzione del codice JavaScript. È un componente separato dal motore di rendering, con cui condivide il DOM di ogni pagina Web renderizzata. Quella condivisione è il cardine di tutto lo sviluppo client-side: il DOM tree costruito dal parser HTML non è un artefatto interno e privato del motore di rendering, è una struttura dati esposta, e l'interprete JavaScript è il componente che la può leggere e modificare. Ogni riga di codice front-end che scriverete nel resto del corso vive in questo punto esatto dell'architettura.
Presente in Chrome, insieme a Blink, e in Node.js; sviluppato da Google. È il motore che rende possibile lo stack MEAN/MEVN del capitolo 1: lo stesso engine che esegue JavaScript nel browser lo esegue anche sul server, ed è questa coincidenza che sta dietro al JavaScript everywhere.
Presente in Firefox, insieme a Gecko; sviluppato da Mozilla, nato per Netscape Navigator. È quindi il più antico dei tre: risale al browser che perse la prima guerra dei browser, ed è sopravvissuto al proprio contenitore originario.
Presente in Safari; sviluppato da Apple. Completa la corrispondenza tra famiglie di motori: a ogni motore di rendering corrisponde storicamente un motore JavaScript sviluppato dalla stessa organizzazione.
Le slide approfondiscono V8, che è scritto in C++ e supporta ECMAScript. È stato progettato da Google con l'obiettivo di migliorare le performance dell'esecuzione di JavaScript all'interno dei browser, e la strategia con cui lo fa è dichiarata senza ambiguità: per ottenere questi miglioramenti, V8 traduce il codice JavaScript in un codice macchina più efficiente, invece di usare un interprete. Più precisamente, V8 compila JavaScript in codice macchina all'esecuzione, implementando un compilatore JIT (Just-In-Time), come fanno altri JavaScript engine — per esempio SpiderMonkey.
La pipeline interna è in tre passaggi:
La parola "interprete" nel nome della componente è ormai un residuo storico, e la stessa slide lo dice tra le righe: V8 nasce proprio per non essere un interprete puro. Il senso della compilazione JIT è che il motore non può compilare tutto in anticipo — riceve il codice al momento del caricamento della pagina — ma può compilare al volo ciò che sta effettivamente eseguendo, ottenendo così codice macchina nativo senza rinunciare al modello di distribuzione del Web, in cui il sorgente viaggia in chiaro e viene eseguito dove atterra.
Tre affermazioni su V8 sono da tenere insieme, perché la domanda tipica le mette in contrasto. Uno: l'interprete JavaScript è un componente separato dal motore di rendering, ma condivide con esso il DOM di ogni pagina renderizzata. Due: V8 non interpreta, traduce in codice macchina tramite compilazione JIT, e lo fa per ragioni di performance. Tre: la catena è sorgente → AST → bytecode → codice macchina nativo, con il JIT che interviene sull'ultimo passaggio. Aggiungete che V8 è usato anche da Node.js e avete il collegamento con lo stack del corso.
L'ultima componente è quella che nella Tavola 2.1 sta a lato di tutte le altre. La data persistence si occupa di ospitare e gestire i dati e le informazioni che il browser deve salvare localmente, per esempio i cookie; altri meccanismi di storage sono supportati dal browser tramite specifiche API — localStorage, sessionStorage, WebSQL, IndexedDB, FileSystem. Le slide la descrivono con un'immagine efficace: si tratta di una specie di database creato in locale, sul calcolatore su cui il browser è installato. Gestisce i dati dell'utente: cache, cookie, bookmark e preferenze dell'utente.
Tra le varie API del browser che consentono l'archiviazione dei dati, la lezione ne dettaglia quattro.
| Meccanismo | Caratteristiche |
|---|---|
| Local storage e Session storage | Permettono di archiviare dati, oggetti e funzioni JS nel browser. Session storage mantiene i dati sul browser fintanto che la sessione è attiva; Local storage li mantiene in locale sul browser in modo persistente. Il limite è di 5 MB per storage e di 50 MB per sistema |
| Cookies | Sono scambiati tra browser e server. Molto utili in contesti specifici, come quelli correlati agli aspetti di privacy e sicurezza, oppure possono essere utilizzati per profilare l'utente. Sono meno performanti e considerati "costosi" rispetto agli altri metodi |
| AppCache | Introdotta come API di HTML5. Memorizza contenuti statici |
| Service Workers | API introdotta da Google. Permette di fare caching dei dati di siti web per il loro utilizzo in modalità offline. Le funzionalità sono molto simili a quelle di AppCache |
La differenza fondamentale, e quella che spiega la nota sui costi, è dove vanno i dati. I cookie sono l'unico meccanismo di questo elenco che viene scambiato con il server: viaggiano avanti e indietro sulla rete, e quindi ogni byte messo in un cookie diventa un byte trasmesso a ogni richiesta. Ecco perché sono considerati costosi e poco performanti rispetto agli altri metodi, che restano invece confinati sul calcolatore dell'utente. Da questa asimmetria discende anche il loro doppio ruolo, tecnico e politico: sono lo strumento che permette a un server di riconoscervi, e quindi al tempo stesso il fondamento della gestione delle sessioni e l'oggetto di tutta la normativa sulla privacy.
L'altra distinzione da tenere ferma è quella tra sessionStorage e localStorage, che hanno la stessa API e la stessa capienza ma un ciclo di vita opposto: il primo dura quanto la sessione, il secondo è persistente. E notate infine il ritorno di AppCache e Service Workers, già incontrati nella sezione sul networking: la loro presenza in entrambe le sezioni non è una ripetizione, è la conseguenza del fatto che fare cache significa esattamente scrivere in uno storage locale ciò che è arrivato dalla rete. Sono il ponte tra la componente 4 e la componente 7, e sono la tecnologia che consente a un'applicazione Web di funzionare offline — cioè, alla fine, di comportarsi come un'applicazione nativa.
I limiti dichiarati — 5 MB per storage e 50 MB per sistema — sono numeri da ricordare, e sono anche un vincolo di progetto reale. Non potete usare localStorage come database di un'applicazione: serve a conservare preferenze, token di sessione, piccole cache di dati. Per volumi maggiori o per query strutturate esistono IndexedDB e, storicamente, WebSQL; e per la persistenza vera resta il server, con il database documentale che l'elaborato del corso richiede esplicitamente.
Le sette componenti sono interfaccia utente, browser engine, rendering engine, networking (client HTTP), interprete JavaScript, UI backend e data persistence. La struttura è a strati: l'interfaccia utente riceve gli input dell'utente e li passa al browser engine, che fa da ponte verso il rendering engine; quest'ultimo si appoggia a tre servizi alla base — il networking che gli consegna i byte, l'interprete JavaScript con cui condivide il DOM, e l'UI backend a cui delega il disegno effettivo degli elementi grafici. La data persistence è disegnata a lato perché è un servizio trasversale: ospita cookie, storage, cache, bookmark e preferenze.
Interfaccia utente e UI backend vanno tenute distinte. La prima è l'insieme concreto degli elementi con cui l'utente interagisce — barra degli indirizzi, bottoni Back e Next, bookmark, tab — cioè tutto ciò che sta fuori dal viewport; non è definita da alcuno standard formale, deriva da best practice che i browser hanno imitato l'uno dall'altro, e HTML5 si limita a elencarne alcuni elementi comuni. Il secondo è il componente che disegna gli elementi grafici di base (widget, bottoni, combo box, finestre) esponendo un'interfaccia generica basata sull'interfaccia del sistema operativo e sui suoi widget: serve sia all'interfaccia utente sia al motore di rendering, che nella fase di painting disegna ogni nodo del render tree sulla base del componente UI backend corrispondente.
Perché deve necessariamente restare in attesa delle risorse che arrivano dai server per comporre la pagina Web: nessun altro componente può procedere su byte non ancora arrivati. La mitigazione descritta a lezione è la cache: il networking può implementare una cache dei documenti recuperati per ridurre il traffico sulla rete, e il browser controlla per primo se esistono cache di dati e risorse nella memoria locale per ogni URL richiesto; solo se non trova nulla crea una richiesta HTTP con un domain name verso la rete. Se il sito implementa a sua volta una cache, viene fatta una copia dei dati in AppCache o Service Workers, per risposte rapide su accessi regolari e ripetuti.
Perché HTML non può essere definito da una grammatica context-free, a differenza di CSS e JavaScript: può essere definito formalmente tramite DTD o XMLSchema, ma non si tratta di una grammatica context-free. La causa è la sintassi "soft" che ammette non conformità, per esempio l'omissione di certi tag e in particolare di quelli di chiusura. Gli algoritmi tradizionali, top-down o bottom-up, presuppongono invece una grammatica context-free. Si aggiungono due ragioni pratiche: l'elevata tolleranza agli errori tradizionale dei browser e il fatto che il sorgente HTML sia dinamico e possa cambiare durante il parsing stesso.
L'algoritmo, descritto nella specifica HTML5, ha due fasi. La prima è la tokenization, l'analisi lessicale che trasforma il codice HTML di input in token (nomi di tag di apertura, valori degli attributi, caratteri): è espressa come un automa a stati finiti in cui ogni stato consuma uno o più caratteri e aggiorna lo stato successivo, partendo dal Data state e passando per Tag open state e Tag name state. La seconda è la tree construction: ogni token emesso viene processato dal Tree constructor, che decide quale elemento DOM creare e lo aggiunge al DOM tree, mantenendo uno stack degli elementi aperti per annidare correttamente anche in presenza di tag non chiusi; anche questo è un automa a stati finiti, i cui stati si chiamano insertion mode. Le due fasi non sono sequenziali: il tokenizer riconosce un token, lo passa subito al costruttore dell'albero, poi consuma il carattere successivo.
HTMLHeadElement anche se non c'è il tag <head>?Perché il tree constructor è un automa a insertion mode e alcuni token presuppongono elementi che devono esistere. Ricevuto il token html si passa a "before html mode" con reprocessing del token, si crea HTMLHtmlElement e lo si appende a Document, passando a "before head". Quando arriva il token body, l'automa si trova in uno stato che richiede che la testa sia già stata trattata: viene quindi creato un HTMLHeadElement anche senza il token head e aggiunto all'albero, con passaggio a "in head" e poi "after head"; il token body viene poi riprocessato, creando HTMLBodyElement e passando a "in body". È il meccanismo generale con cui la specifica produce un albero ben formato anche da markup incompleto.
Non restituiscono mai errori di tipo "Invalid Syntax" sulle pagine HTML: si prendono l'onere di correggere gli errori di invalidità del markup per poter caricare comunque la pagina. L'esempio delle slide contiene un tag inventato, un <p> aperto dentro un <div> e chiuso fuori, e altri errori: il browser lo mostra correttamente grazie al lavoro del parser. Il fatto notevole è che la gestione degli errori è abbastanza consistente tra tutti i browser anche se non è inclusa nella specifica dell'HTML: gli errori più frequenti vengono gestiti in modo conforme gli uni con gli altri, sulla base di anni di esperienze simili. È una convergenza de facto, non una garanzia normativa.
Il parsing del CSS produce un oggetto StyleSheet, che contiene le regole CSS, ciascuna composta da selettore e dichiarazioni secondo la grammatica CSS. È più semplice del parsing HTML perché CSS ha una grammatica context-free: la specifica definisce una grammatica sintattica e una lessicale, e questo permette di effettuare il parsing con parser tradizionali, senza algoritmi customizzati e senza tolleranza agli errori implementata a mano.
Prima ancora della cascata valgono due regole: se una proprietà non è definita esplicitamente da alcuna regola viene ereditata dall'elemento padre, e alcune proprietà hanno valori di default (per esempio background-color). L'ordine della cascata, dal più basso al più alto, è: foglio di stile di default del browser, preferenze dell'utente, foglio di stile dell'autore (esterno, interno, inline più gli attributi HTML), foglio di stile dell'autore con !important, preferenze dell'utente con !important. Notate l'inversione finale: l'autore vince sull'utente nei livelli normali, ma l'utente vince sull'autore quando entrambi usano !important, ed è la valvola di sicurezza che permette a chi ha esigenze di accessibilità di imporre le proprie preferenze.
Perché lo scarto avviene in due direzioni. Ci sono nodi DOM senza alcun renderer: gli elementi non visuali, come <head> e i metatag, e gli elementi con la regola display: none; — mentre quelli con visibility: hidden; compaiono nel render tree, perché occupano comunque il proprio rettangolo. E ci sono nodi DOM con più renderer: alcuni elementi hanno una struttura complessa e non possono essere descritti con un solo rettangolo, e l'esempio citato è <select>, che richiede 3 renderer. Ricordate anche la nomenclatura: Gecko chiama frames gli elementi del render tree, WebKit li chiama renderer o render object.
<div style="width: 30%"/>.Quando un renderer viene creato e aggiunto all'albero non ha ancora informazioni di posizione e dimensioni: il calcolo di questi valori è il layout, detto anche reflow. È un processo ricorsivo che comincia dal root renderer, corrispondente a <html>, la cui posizione è 0,0 e le cui dimensioni sono quelle del viewport; le coordinate sono relative all'area di visualizzazione, con origine nell'angolo in alto a sinistra. La larghezza scende dal padre, mentre il padre assegna a ogni figlio le coordinate x e y, chiama il layout del figlio ottenendone l'altezza, e usa le altezze dei figli (con margin e padding) per calcolare la propria. Per il <div> al 30%: si calcola la width del contenitore come width della finestra esclusi bordi e scrollbar, meno padding-left e padding-right; si applica la percentuale a quel valore; infine si aggiungono gli spessori dei bordi orizzontali e i padding.
Lo stabilisce la specifica CSS, e procede dal background verso il foreground. Per ogni renderer di blocco l'ordine è: 1. background-color, 2. background-image, 3. border, 4. children, 5. outline. Operativamente il browser visita il render tree e crea una lista degli elementi da disegnare per ogni rettangolo, contenente i renderer rilevanti per quel rettangolo nell'ordine corretto; ogni nodo viene poi disegnato sulla base del componente UI backend corrispondente. Il dettaglio da notare è che i figli sono al quarto posto: dopo sfondo e bordo del genitore, prima del suo outline.
L'interprete JavaScript è un componente separato dal motore di rendering, con cui però condivide il DOM di ogni pagina renderizzata: è questa condivisione che permette al codice client-side di leggere e modificare il documento. V8 è scritto in C++, supporta ECMAScript ed è stato progettato da Google per migliorare le performance: traduce il codice JavaScript in codice macchina più efficiente invece di usare un interprete, compilando all'esecuzione tramite un compilatore JIT (Just-In-Time), come fa anche SpiderMonkey. La pipeline è: parsing dello script per creare un AST; l'AST in input al Bytecode Generator per produrre il bytecode; il bytecode interpretato con l'aiuto del JIT compiler per produrre il linguaggio macchina nativo che viene eseguito. Gli altri due motori citati sono SpiderMonkey (Firefox, con Gecko, di Mozilla, nato per Netscape Navigator) e JavaScriptCore (Safari, di Apple).
La data persistence è una specie di database creato in locale sul calcolatore su cui il browser è installato, e gestisce cache, cookie, bookmark e preferenze dell'utente. Local storage e Session storage archiviano dati, oggetti e funzioni JS: il session storage dura fintanto che la sessione è attiva, il local storage è persistente; il limite è 5 MB per storage e 50 MB per sistema. I cookies sono l'unico meccanismo scambiato tra browser e server, utili per privacy, sicurezza e profilazione dell'utente, ma meno performanti e considerati costosi rispetto agli altri. AppCache è un'API di HTML5 che memorizza contenuti statici; i Service Workers sono un'API introdotta da Google che permette il caching dei dati di siti web per l'utilizzo offline, con funzionalità molto simili ad AppCache. Sono citati inoltre WebSQL, IndexedDB e FileSystem.
Perché ogni tab del browser lancia una propria istanza del motore di rendering in un processo separato. Le conseguenze sono due, una positiva e una da mettere in conto. La positiva è l'isolamento: una pagina che manda in crash il proprio motore di rendering non trascina con sé l'intero browser, e i dati delle altre schede restano al sicuro. Quella da mettere in conto è il costo in memoria, che cresce con il numero di schede aperte. Va letta insieme al carattere graduale del rendering: il motore non attende il completamento del parsing di tutta la pagina prima di costruire il render tree, ma mostra ciò che ha già elaborato mentre il resto continua ad arrivare dal client HTTP.