Approfondimento su RenderingNG: frammentazione dei blocchi LayoutNG

Morten Stenshorne
Morten Stenshorne

La frammentazione dei blocchi suddivide una casella a livello di blocco CSS (ad esempio una sezione o un paragrafo) in più frammenti quando non si adatta interamente all'interno di un container di frammenti, chiamato fragmentainer. Un fragmentainer non è un elemento, ma rappresenta una colonna in un layout a più colonne o una pagina in contenuti multimediali impaginati.

Affinché si verifichi la frammentazione, i contenuti devono trovarsi all'interno di un contesto di frammentazione. In genere, il contesto di frammentazione viene stabilito da un contenitore a più colonne (i contenuti sono suddivisi in colonne) o durante la stampa (i contenuti sono suddivisi in pagine). Un paragrafo lungo con molte righe potrebbe dover essere suddiviso in più frammenti, in modo che le prime righe siano posizionate nel primo frammento e le righe rimanenti vengano posizionate in frammenti successivi.

Un paragrafo di testo suddiviso in due colonne.
In questo esempio, un paragrafo è stato suddiviso in due colonne utilizzando il layout a più colonne. Ogni colonna è un fragmentainer, che rappresenta un frammento del flusso frammentato.

La frammentazione dei blocchi è analoga a un altro tipo noto di frammentazione: la frammentazione delle linee, altrimenti nota come "interruzione di linea". Qualsiasi elemento incorporato composto da più di una parola (qualsiasi nodo di testo, elemento <a> e così via) che consenta interruzioni di riga può essere suddiviso in più frammenti. Ogni frammento è posizionato in un riquadro di linea diverso. Un riquadro a linee è la frammentazione in linea equivalente a un fragmentainer per colonne e pagine.

Frammentazione dei blocchi LayoutNG

LayoutNGBlockFragmentation è una riscrittura del motore di frammentazione per LayoutNG, inizialmente disponibile in Chrome 102. In termini di strutture di dati, ha sostituito diverse strutture di dati precedenti a NG con frammenti NG rappresentati direttamente nell'albero dei frammenti.

Ad esempio, ora supportiamo il valore "avoid" per le proprietà CSS "break-before" e "break-after", che consentono agli autori di evitare interruzioni subito dopo un'intestazione. Spesso ha un aspetto imbarazzante quando l'ultima cosa in una pagina è l'intestazione e i contenuti della sezione iniziano dalla pagina successiva. È preferibile interrompere l'azione prima dell'intestazione.

Esempio di allineamento delle intestazioni.
Figura 1. Il primo esempio mostra un'intestazione in fondo alla pagina, il secondo in cima alla pagina successiva, insieme ai contenuti associati.

Chrome supporta anche l'overflow della frammentazione, in modo che i contenuti monolitici (previsti siano infrangibili) non vengano suddivisi in più colonne e gli effetti di colorazione come ombre e trasformazioni vengano applicati correttamente.

La frammentazione dei blocchi in LayoutNG è stata completata

Frammentazione del nucleo (container di blocchi, inclusi layout a linee, numeri in virgola mobile e posizionamento out-of-flow) disponibili in Chrome 102. Frammentazione di Flex e griglia disponibile in Chrome 103, mentre la frammentazione delle tabelle è disponibile in Chrome 106. Infine, la stampa viene fornita con Chrome 108. La frammentazione dei blocchi è stata l'ultima funzionalità che dipendeva dal motore precedente per l'esecuzione del layout.

A partire da Chrome 108, il motore precedente non viene più utilizzato per eseguire il layout.

Inoltre, le strutture di dati LayoutNG supportano la pittura e l'hit test, ma ci basiamo su alcune strutture di dati precedenti per le API JavaScript che leggono le informazioni sul layout, come offsetLeft e offsetTop.

La disposizione di tutti gli elementi con NG consentirà di implementare e distribuire nuove funzionalità che hanno solo implementazioni LayoutNG (e nessuna controparte del motore legacy), come le query sui container CSS, il posizionamento degli ancoraggi, MathML e layout personalizzato (Houdini). Per le query relative al contenitore, l'abbiamo spedito con un po' di anticipo, avvisando gli sviluppatori che la stampa non era ancora supportata.

Abbiamo distribuito la prima parte di LayoutNG nel 2019, che consisteva in layout di container a blocchi regolari, layout in linea, float e posizionamento out-of-flow, ma nessun supporto per flex, griglia o tabelle e nessun supporto per la frammentazione dei blocchi. Ricorriamo all'utilizzo del motore di layout precedente per flex, griglia, tabelle e tutto ciò che comporta la frammentazione dei blocchi. Questo vale anche per gli elementi a blocchi, in linea, mobili e out-of-flow all'interno di contenuti frammentati: come puoi vedere, eseguire l'upgrade in presenza di un motore di layout così complesso è un ballo molto delicato.

Inoltre, entro la metà del 2019, la maggior parte della funzionalità di base del layout di frammentazione a blocchi LayoutNG era già stata implementata (dietro un flag). Perché la spedizione ha richiesto così tanto tempo? La risposta breve è che la frammentazione deve coesistere correttamente con varie parti legacy del sistema, che non possono essere rimosse o aggiornate fino a quando non viene eseguito l'upgrade di tutte le dipendenze.

Interazione del motore precedente

Le strutture di dati legacy sono ancora responsabili delle API JavaScript che leggono le informazioni sul layout, quindi dobbiamo riscrivere i dati al motore legacy in modo che possa comprenderli. Ciò include l'aggiornamento corretto delle strutture dati a più colonne legacy, come LayoutMultiColumnFlowThread.

Rilevamento e gestione dei fallback dei motori legacy

Dovevamo ricorrere al motore di layout precedente quando all'interno c'erano contenuti che non potevano ancora essere gestiti dalla frammentazione dei blocchi LayoutNG. Al momento della frammentazione dei blocchi LayoutNG del nucleo di spedizione, che includeva elementi flessibili, griglie, tabelle e qualsiasi elemento stampato. Questo è stato particolarmente complicato perché dovevamo rilevare la necessità di una versione di riserva precedente prima di creare oggetti nell'albero del layout. Ad esempio, dovevamo rilevare prima di sapere se esisteva un predecessore di container a più colonne e prima di sapere quali nodi DOM sarebbero diventati un contesto di formattazione o meno. Si tratta di un problema di pollo e uova che non ha una soluzione perfetta, ma finché il suo unico comportamento scorretto è costituito da falsi positivi (il fallback alla versione precedente quando in realtà non ce n'è più bisogno), va bene, perché eventuali bug nel comportamento del layout sono quelli già esistenti in Chromium, non nuovi.

Passeggiata tra gli alberi prima del colore

La pre-colorazione è un'operazione che eseguiamo dopo il layout, ma prima di dipingere. La sfida principale è che dobbiamo ancora percorrere l'albero di oggetti di layout, ma ora abbiamo frammenti NG, allora come possiamo risolvere il problema? Percorriamo contemporaneamente sia l'oggetto di layout sia gli alberi con frammenti NG. Questo è abbastanza complicato, perché la mappatura tra i due alberi non è banale.

La struttura ad albero degli oggetti di layout è molto simile a quella dell'albero DOM, ma l'albero dei frammenti è un output del layout, non un input. Oltre a riflettere effettivamente l'effetto di qualsiasi frammentazione, inclusa la frammentazione in linea (frammenti di linea) e la frammentazione dei blocchi (frammenti di colonne o pagine), l'albero dei frammenti ha anche una relazione padre-figlio diretta tra un blocco contenente e i discendenti DOM che hanno quel frammento come blocco contenitore. Ad esempio, nell'albero dei frammenti, un frammento generato da un elemento con posizione assolutamente in posizione è un elemento figlio diretto del suo frammento contenitore contenitore, anche se nella catena di antenati sono presenti altri nodi tra il discendente posizionato fuori flusso e il blocco contenitore.

Può essere ancora più complicato in presenza di un elemento posizionato fuori flusso all'interno della frammentazione, perché i frammenti out-of-flow diventano figli diretti del fragmentainer (e non figli di ciò che CSS pensa sia il blocco contenitore). Si trattava di un problema che doveva essere risolto per coesistere con il motore legacy. In futuro, dovremmo essere in grado di semplificare questo codice, perché LayoutNG è progettato per supportare in modo flessibile tutte le modalità di layout moderne.

I problemi del motore di frammentazione legacy

Il motore legacy, progettato in una fase precedente del web, in realtà non ha un concetto di frammentazione, anche se tecnicamente esisteva anche a quel tempo (per supportare la stampa). Il supporto per la frammentazione era semplicemente imbullonato (per la stampa) o montato in un secondo momento (a più colonne).

Durante il layout di contenuti frammentabili, il motore legacy suddivide tutto in una striscia alta, la cui larghezza corrisponde alle dimensioni in linea di una colonna o di una pagina e che l'altezza è pari a quella necessaria per includere i contenuti. Questa striscia lunga non viene visualizzata nella pagina: può essere paragonata a una pagina virtuale che viene poi riorganizzata per la visualizzazione finale. È concettualmente simile alla stampa di un intero articolo di giornale cartaceo in una colonna e all'utilizzo delle forbici per ritagliarlo in più pezzi come secondo passaggio. Un tempo, alcuni giornali utilizzavano in realtà tecniche simili a questa.

Il motore legacy tiene traccia del confine di una pagina o colonna immaginaria nella striscia. In questo modo, i contenuti che non vanno oltre il limite vengono spostati nella pagina o nella colonna successiva. Ad esempio, se solo la metà superiore di una linea può adattarsi alla pagina corrente, viene inserito un "punto di paginazione" per spingerlo verso il basso nella posizione in cui il motore presuppone che si trovi la parte superiore della pagina successiva. Quindi, la maggior parte dell'effettiva frammentazione (il "taglio con le forbici e il posizionamento") avviene dopo il layout durante il pre-paint e la pittura, taglio dei contenuti alti o delle strisce. Ciò ha reso alcune cose essenzialmente impossibili, come l'applicazione delle trasformazioni e il posizionamento relativo dopo la frammentazione (che è ciò che la specifica richiede). Inoltre, sebbene nel motore legacy sia presente un certo supporto per la frammentazione delle tabelle, non è previsto alcun supporto di flessione o frammentazione della griglia.

Ecco un'illustrazione di come un layout a tre colonne viene rappresentato all'interno del motore precedente, prima di usare forbici, posizionamento e colla (abbiamo un'altezza specificata, in modo che ci siano solo quattro righe, ma c'è dello spazio in eccesso nella parte inferiore):

La rappresentazione interna come una colonna con colonne di impaginazione in cui si interrompe il contenuto e la rappresentazione sullo schermo come tre colonne

Poiché il motore di layout precedente non frammenta effettivamente i contenuti durante il layout, sono presenti molti artefatti strani, come il posizionamento relativo e le trasformazioni applicate in modo errato e le ombre dei riquadri che vengono tagliate sui bordi delle colonne.

Ecco un esempio con text-shadow:

Il motore precedente non gestisce bene questa situazione:

Ombre del testo ritagliate nella seconda colonna.

Vedi come l'ombra del testo della riga nella prima colonna viene ritagliata e posizionata nella parte superiore della seconda colonna? Questo perché il motore di layout precedente non comprende la frammentazione.

Dovrebbe avere il seguente aspetto:

Due colonne di testo con le ombre visualizzate correttamente.

Ora rendiamo tutto un po' più complicato con le trasformazioni e box-shadow. Nota come nel motore precedente ci sono tagli e pagine senza errori nelle colonne. Questo perché, in base alle specifiche, le trasformazioni dovrebbero essere applicate come effetto post-layout e post-frammentazione. Con la frammentazione LayoutNG, entrambi funzionano correttamente. Ciò aumenta l'interoperabilità con Firefox, che da tempo supporta la frammentazione con un buon supporto, con il superamento della maggior parte dei test in quest'area.

Le caselle sono suddivise erroneamente in due colonne.

Il motore precedente presenta problemi anche con contenuti monolitici alti. I contenuti sono monolitici se non sono idonei alla suddivisione in più frammenti. Gli elementi con scorrimento extra sono monolitici, perché non ha senso per gli utenti scorrere in un'area non rettangolare. Le caselle a riga e le immagini sono altri esempi di contenuti monolitici. Esempio:

Se i contenuti monolitici sono troppo alti per essere inseriti in una colonna, il motore legacy la suddividerà in modo brutale (comportando un comportamento molto "interessante" quando tenti di far scorrere il contenitore scorrevole):

Invece di lasciarlo sovraccaricare la prima colonna (come fa con la frammentazione dei blocchi LayoutNG):

ALT_TEXT_HERE

Il motore precedente supporta le interruzioni forzate. Ad esempio, <div style="break-before:page;"> inserisce un'interruzione di pagina prima del DIV. Tuttavia, il suo supporto limitato per la ricerca di interruzioni non forzate ottimali è limitato. Supporta break-inside:avoid e orfani e vedove, ma non è possibile evitare le pause tra i blocchi, se richiesto, ad esempio, tramite break-before:avoid. Considera questo esempio:

Testo suddiviso in due colonne.

In questo caso, l'elemento #multicol ha spazio per 5 righe in ogni colonna (perché è alto 100 px e l'altezza della riga è 20 px), quindi tutti i valori #firstchild potrebbero rientrare nella prima colonna. Tuttavia, l'elemento di pari livello #secondchild ha break-before:avoid, il che significa che i contenuti vogliono che non si verifichi una pausa tra di loro. Poiché il valore di widows è 2, dobbiamo eseguire il push di 2 righe di #firstchild alla seconda colonna, per soddisfare tutte le richieste di evitare le interruzioni. Chromium è il primo motore del browser che supporta pienamente questa combinazione di funzionalità.

Come funziona la frammentazione NG

Il motore di layout NG in genere delinea il documento attraversando la struttura di caselle CSS in profondità. Quando tutti i discendenti di un nodo sono disposti, il layout di quel nodo può essere completato generando un NGPhysicalFragment e tornando all'algoritmo di layout padre. Questo algoritmo aggiunge il frammento al proprio elenco di frammenti figlio e, una volta completati tutti gli elementi figlio, genera un frammento per sé con tutti i suoi frammenti figlio all'interno. Con questo metodo crea un albero di frammenti per l'intero documento. Tuttavia, questa è una semplificazione eccessiva: ad esempio, gli elementi posizionati fuori flusso dovranno apparire da dove si trovano nell'albero DOM al loro blocco contenitore prima di poter essere disposti. Ho scelto di ignorare questi dettagli avanzati qui per semplicità.

Insieme alla casella CSS stessa, LayoutNG fornisce uno spazio vincolo a un algoritmo di layout. Ciò fornisce all'algoritmo informazioni come lo spazio disponibile per il layout, se è stato stabilito un nuovo contesto di formattazione e il margine intermedio che comprime i risultati dei contenuti precedenti. Lo spazio del vincolo conosce anche la dimensione del blocco stratificata del fragmentainer e l'offset del blocco corrente al suo interno. Questo indica il punto in cui interrompere l'operazione.

Quando è prevista la frammentazione dei blocchi, il layout dei discendenti deve fermarsi in un punto di interruzione. I motivi dell'interruzione sono l'esaurimento dello spazio nella pagina o nella colonna oppure un'interruzione forzata. Produciamo quindi frammenti per i nodi visitati e torniamo fino alla radice del contesto di frammentazione (il container multicol o, nel caso della stampa, la radice del documento). Quindi, alla radice del contesto della frammentazione, ci prepariamo per un nuovo fragmentainer e scendiamo di nuovo nell'albero, riprendendo da dove avevamo interrotto prima dell'interruzione.

La struttura dei dati cruciale per fornire i mezzi per riprendere il layout dopo una pausa è chiamata NGBlockBreakToken. Contiene tutte le informazioni necessarie per riprendere correttamente il layout nel successivo fragmentainer. Un NGBlockBreakToken è associato a un nodo e forma un albero NGBlockBreakToken, in modo che ogni nodo da riprendere sia rappresentato. Un NGBlockBreakToken è collegato all'elemento NGPhysicalBoxFragment generato per i nodi che si rompono all'interno. I token di interruzione vengono propagati agli elementi padre, formando una struttura di token di interruzione. Se è necessario eseguire l'interruzione prima di un nodo (anziché al suo interno), non verrà generato alcun frammento, ma il nodo padre deve comunque creare un token di interruzione "break-before" per il nodo, in modo da poter iniziare a distribuirlo quando arriviamo alla stessa posizione nell'albero dei nodi nel successivo fragmentainer.

Le interruzioni vengono inserite quando esauriamo lo spazio fragmentainer (un'interruzione non forzata) o quando viene richiesta un'interruzione forzata.

La specifica prevede regole per ottimizzare le interruzioni non forzate e il semplice inserimento di un'interruzione nel punto esatto in cui si esaurisce lo spazio non è sempre la cosa giusta da fare. Ad esempio, esistono varie proprietà CSS come break-before che influenzano la scelta della posizione delle interruzioni.

Durante il layout, per implementare correttamente la sezione della specifica relativa alle interruzioni non forzate, dobbiamo tenere traccia di eventuali punti di interruzione validi. Questo record significa che possiamo tornare indietro e utilizzare l'ultimo miglior punto di interruzione possibile trovato, se esauriamo lo spazio in un punto in cui violiamo le richieste di evasione delle interruzioni (ad esempio, break-before:avoid o orphans:7). A ogni possibile punto di interruzione viene assegnato un punteggio, che va da "Esegui questa operazione solo come ultima risorsa" a "il posto perfetto dove rompere", con alcuni valori intermedi. Se il punteggio della posizione di una interruzione è "perfetto", significa che non verrà violata alcuna regola che viola le norme (e se otteniamo questo punteggio esattamente nel punto in cui si esaurisce lo spazio, non c'è bisogno di cercare qualcosa di migliore). Se il punteggio è "ultima risorsa", il punto di interruzione non è nemmeno valido, ma potremmo interromperlo se non troviamo nulla di migliore, per evitare l'overflow di fragmentainer.

In genere, i punti di interruzione validi si verificano solo tra elementi di pari livello (riquadri o blocchi di riga) e non, ad esempio, tra un elemento padre e il primo elemento figlio (i punti di interruzione di classe C sono un'eccezione, ma non è necessario parlarne qui). Ad esempio, esiste un punto di interruzione valido prima di un gemello di blocco con break-before:avoid, ma si trova a metà strada tra "perfect" e "last-resort".

Durante il layout teniamo traccia del miglior punto di interruzione trovato finora in una struttura chiamata NGEarlyBreak. Un'interruzione anticipata è un possibile punto di interruzione prima o all'interno di un nodo di blocco oppure prima di una linea (una linea di container di blocchi o una linea flessibile). Potremmo formare una catena o un percorso di oggetti NGEarlyBreak, nel caso in cui il miglior punto di interruzione sia in qualche punto all'interno di qualcosa che abbiamo superato in precedenza quando abbiamo esaurito lo spazio. Esempio:

In questo caso, lo spazio disponibile è esaurito subito prima del giorno #second, ma è presente il valore "break-before:avoid", che riceve un punteggio relativo alla posizione delle interruzioni "break-before" (evitare l'interruzione in violazione). A quel punto abbiamo una catena NGEarlyBreak di "inside #outer > all'interno di #middle > all'interno di #inner > prima di "riga 3"', con "perfect", quindi vorremmo interrompere lì. Dobbiamo quindi restituire ed eseguire nuovamente il layout dall'inizio di #outer (e questa volta passare l'elemento NGEarlyBreak che abbiamo trovato), in modo da poter interrompere prima della "riga 3" in #inner. (Interrompiamo prima della "riga 3", in modo che le 4 righe rimanenti finiscano nel successivo fragmentainer e in modo da rispettare widows:4.)

L'algoritmo è progettato per rompere sempre nel miglior punto di interruzione possibile, come definito nella specifica, eliminando le regole nell'ordine corretto, se non sono soddisfatte tutte. Tieni presente che dobbiamo effettuare il relayout al massimo una volta per ogni flusso di frammentazione. Nel momento in cui ci troviamo nel secondo passaggio di layout, la posizione migliore dell'interruzione è già stata passata agli algoritmi di layout, ovvero la posizione dell'interruzione scoperta nel primo passaggio di layout e fornita come parte dell'output del layout in quel round. Nel secondo passaggio del layout, non lo distribuiamo finché lo spazio non sarà esaurito. Infatti, non è previsto che lo spazio si esaurisca (potrebbe essere un errore), perché abbiamo un posto super-dolce (beh, dolce come era disponibile) in cui inserire una pausa anticipata, per evitare di violare inutilmente le regole. Quindi ci limitiamo ad allenarci e interrompiamo.

A questo proposito, a volte dobbiamo violare alcune richieste di evasione delle interruzioni, se ciò contribuisce a evitare l'overflow di fragmentainer. Ad esempio:

In questo caso lo spazio è esaurito poco prima del giorno #second, ma c'è il comando "break-before:avoid". Ciò significa "evitare interruzioni in violazione ", proprio come nell'ultimo esempio. Abbiamo anche un NGEarlyBreak con "orfani e vedove in violazione" (all'interno di #first > prima di"riga 2"), che non è comunque perfetto, ma meglio di "violare la pausa evitare". Quindi ci soffermeremo prima della "riga 2" violando la richiesta orfani / vedove. La specifica tratta questo aspetto nella sezione 4.4. Interruzioni non forzate, dove definisce quali regole di interruzione vengono ignorate per prime se non sono presenti punti di interruzione sufficienti per evitare l'overflow di fragmentainer.

Conclusione

L'obiettivo funzionale del progetto di frammentazione dei blocchi LayoutNG era fornire l'implementazione a supporto dell'architettura LayoutNG di tutto ciò che supporta il motore legacy e il meno altro possibile, a parte le correzioni di bug. L'eccezione principale è il supporto migliore per evitare le interruzioni (break-before:avoid, ad esempio), perché questa è una parte fondamentale del motore di frammentazione, quindi è stato necessario intervenire fin dall'inizio, poiché la sua aggiunta in un secondo momento significherebbe eseguire un'altra riscrittura.

Ora che la frammentazione dei blocchi LayoutNG è terminata, possiamo iniziare a lavorare all'aggiunta di nuove funzionalità, ad esempio il supporto di dimensioni di pagina miste durante la stampa, @page di caselle a margine durante la stampa, box-decoration-break:clone e altro ancora. Come per LayoutNG in generale, ci aspettiamo che la percentuale di bug e l'onere di manutenzione del nuovo sistema siano sostanzialmente inferiori nel tempo.

Ringraziamenti