Approfondimento su RenderingNG: frammentazione dei blocchi LayoutNG

La frammentazione del blocco in LayoutNG è stata completata. Scopri come funziona e perché è importante in questo articolo.

Morten Stenshorne
Morten Stenshorne

Sono Morten Stenshorne, ingegnere di layout nel team di rendering di Blink presso Google. Sono stato coinvolto nello sviluppo del motore dei browser dall'inizio degli anni 2000 e mi sono divertita molto, ad esempio aiutando a superare il test acid2 nel motore Presto (Opera 12 e versioni precedenti) e eseguendo il reverse engineering di altri browser per correggere il layout delle tabelle in Presto. Ho anche trascorso più di questi anni di quanto vorrei ammettere sulla frammentazione dei blocchi e, in particolare, sulla frammentazione dei blocchi multicol in Presto, WebKit e Blink. Negli ultimi anni in Google mi sono concentrata principalmente sulla gestione del lavoro di aggiunta del supporto della frammentazione dei blocchi a LayoutNG. Unisciti a me in questo approfondimento sull'implementazione della frammentazione dei blocchi, dato che potrebbe essere l'ultima volta che implemento la frammentazione. :)

Che cos'è la frammentazione dei blocchi?

La frammentazione dei blocchi comporta la suddivisione di una casella CSS a livello di blocco (ad esempio una sezione o un paragrafo) in più frammenti quando non rientra nel suo insieme all'interno di un contenitore di frammenti chiamato fragmentainer. Un frammentatore non è un elemento, ma rappresenta una colonna con layout a più colonne o una pagina in contenuti multimediali di paging. Affinché si verifichi la frammentazione, i contenuti devono trovarsi all'interno di un contesto di frammentazione. Un contesto di frammentazione viene stabilito in genere da un contenitore a più colonne (i contenuti vengono suddivisi in colonne) o durante la stampa (i contenuti vengono suddivisi in pagine). Potrebbe essere necessario dividere un paragrafo lungo con molte righe in più frammenti, in modo che le prime righe vengano posizionate nel primo frammento e le righe rimanenti nei 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 frammentazione, che rappresenta un frammento del flusso frammentato.

La frammentazione di blocchi è analoga a quella di un altro tipo ben noto: la frammentazione di linea (nota anche come "interruzione di riga"). Gli elementi incorporati costituiti da più di una parola (qualsiasi nodo di testo, elemento <a> e così via) e che consentono l'interruzione di riga possono essere suddivisi in più frammenti. Ogni frammento viene posizionato in un riquadro di riga diverso. Una casella di linea è la frammentazione in linea equivalente a un frammento per colonne e pagine.

Che cos'è la frammentazione dei blocchi LayoutNG?

LayoutNGBlockFragmentation è una riscrittura del motore di frammentazione di LayoutNG e, dopo molti anni di lavoro, le prime parti sono state consegnate in Chrome 102 all'inizio di quest'anno. Questo ha risolto problemi di lunga data che non erano risolvibili nel nostro motore "legacy". In termini di strutture di dati, sostituisce più strutture di dati pre-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 consente agli autori di evitare interruzioni subito dopo un'intestazione. In genere non sembra funzionare se l'ultima cosa inserita in una pagina è un'intestazione, mentre i contenuti della sezione iniziano dalla pagina successiva. È meglio invece interrompere prima dell'intestazione. Puoi vedere un esempio nella figura riportata di seguito.

Il primo esempio mostra un&#39;intestazione in fondo alla pagina, il secondo la mostra con i contenuti associati nella parte superiore della pagina seguente.

Chrome 102 supporta anche l'overflow di frammentazione, in modo che i contenuti monolitici (previsti per essere indistruttibili) non vengano suddivisi in più colonne e gli effetti di disegno come ombre e trasformazioni vengano applicati correttamente.

La frammentazione del blocco in LayoutNG è stata completata

Nel momento in cui scriviamo, abbiamo completato il supporto completo della frammentazione dei blocchi in LayoutNG. Frammentazione principale (container a blocchi, inclusi layout riga, float e posizionamento out-of-flow) spediti in Chrome 102. La frammentazione flessibile e della griglia è stata fornita in Chrome 103, mentre la frammentazione delle tabelle è stata fornita in Chrome 106. Infine, la stampa fornita con Chrome 108. La frammentazione dei blocchi è stata l'ultima funzionalità che dipendeva dal motore precedente per eseguire il layout. Ciò significa che, a partire dalla versione 108 di Chrome, il motore precedente non verrà più utilizzato per eseguire il layout.

Oltre a disporre i contenuti, le strutture dei dati LayoutNG supportano il disegno e gli hit test, ma ci affidiamo ancora ad alcune strutture di dati legacy per le API JavaScript che leggono le informazioni di layout, come offsetLeft e offsetTop.

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

La prima parte di LayoutNG è stata pubblicata nel 2019, che consisteva in un layout normale dei container a blocchi, in un layout in linea, in virgola mobile e nel posizionamento out-of-flow, ma senza supporto per elementi flessibili, griglia o tabelle, e nessun supporto per la frammentazione dei blocchi. Ricorreremmo a utilizzare il motore di layout precedente per flex, griglia, tabelle e tutto ciò che comporta la frammentazione dei blocchi. Ciò valeva anche per gli elementi a blocchi, in linea, floating e out-of-flow all'interno di contenuti frammentati. Come puoi vedere, l'upgrade di un motore di layout così complesso sul posto è un'operazione molto delicata.

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

Interazione del motore precedente

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

Rilevamento e gestione dei fallback dei motori legacy

Abbiamo dovuto ricorrere al motore di layout precedente quando al suo interno erano presenti contenuti che non potevano ancora essere gestiti dalla frammentazione dei blocchi LayoutNG. Al momento della spedizione della frammentazione principale dei blocchi LayoutNG (primavera 2022), che includeva flessibilità, griglia, tabelle e qualsiasi elemento stampato. Questo era particolarmente difficile perché dovevamo rilevare la necessità di un elemento di riserva precedente prima di creare oggetti nell'albero del layout. Ad esempio, dovevamo effettuare il rilevamento prima di sapere se esisteva un predecessore del container a più colonne e prima di sapere quali nodi DOM sarebbero diventati o meno un contesto di formattazione. È un problema con l'uso di uova e galline che non ha una soluzione perfetta, ma fintanto che il suo unico comportamento scorretto sono falsi positivi (fallimento all'eredità quando in realtà non ce n'è bisogno), va bene, perché i bug in questo comportamento del layout sono quelli già presenti in Chromium, non quelli nuovi.

Camminata sugli alberi

Eseguiamo la pre-colorazione dopo il layout, ma prima di dipingere. La sfida principale è che dobbiamo ancora percorrere l'albero degli oggetti di layout, ma ora abbiamo frammenti NG, quindi come possiamo affrontarlo? Camminiamo contemporaneamente sia sull'oggetto di layout sia sugli alberi con frammenti NG. È abbastanza complicato perché la mappatura tra i due alberi non è banale. La struttura ad albero degli oggetti di layout assomiglia molto 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 di blocco (frammenti di colonne o di pagina), la struttura ad albero dei frammenti ha anche una relazione principale-figlio diretta tra un blocco contenitore e i discendenti DOM che hanno quel frammento come blocco contenitore. Ad esempio, nell'albero dei frammenti, un frammento generato da un elemento posizionato in modo assoluto è un elemento figlio diretto del frammento di blocco contenitore, anche se sono presenti altri nodi nella catena di discendenza tra il discendente posizionato out-of-flow e il blocco contenitore.

Diventa ancora più complicato quando c'è un elemento posizionato fuori flusso all'interno della frammentazione, perché i frammenti out-of-flow diventano elementi secondari del fragmentainer (e non un elemento secondario di quello che CSS pensa essere il blocco contenitore). Sfortunatamente, questo era un problema che doveva essere risolto per coesistere con il motore precedente senza troppi problemi. In futuro, dovremmo essere in grado di semplificare gran parte di questo codice, poiché 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 un'era precedente del web, non aveva un concetto di frammentazione, anche se allora esisteva tecnicamente anche la frammentazione (per supportare la stampa). Il supporto alla frammentazione era un elemento fissato con i bulloni sulla parte superiore (stampa) o riadattato (a più colonne).

Quando disponi di contenuti frammentabili, il motore precedente dispone tutto in una striscia alta la cui larghezza corrisponde alle dimensioni in linea di una colonna o di una pagina e l'altezza è pari a quella necessaria per contenere i contenuti. Questa striscia alta non viene visualizzata sulla pagina, può essere considerata un rendering su una pagina virtuale che viene quindi riorganizzata per la visualizzazione finale. È concettualmente simile alla stampa di un intero articolo di giornale cartaceo in una colonna, che poi utilizza le forbici per dividerlo in più elementi come secondo passaggio. In passato, alcuni giornali utilizzavano in realtà tecniche simili a questa!

Il motore precedente tiene traccia dei confini di una pagina o di una colonna immaginari nella striscia. In questo modo, puoi sollecitare i contenuti che non rientrano nel limite nella pagina o colonna successiva. Ad esempio, se solo la metà superiore di una riga rientra in quella che il motore ritiene essere la pagina corrente, verrà 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. In seguito, la maggior parte del lavoro di frammentazione (il "taglio con le forbici e il posizionamento") avviene dopo il layout durante il predisposizione e la colorazione, tagliando le pagine o tagliando le colonne alte. Ciò ha reso alcune cose essenzialmente impossibili, come l'applicazione delle trasformazioni e del posizionamento relativo dopo la frammentazione (che è ciò che la specifica richiede). Inoltre, sebbene sia presente un certo supporto per la frammentazione delle tabelle nel motore legacy, non è presente alcun supporto flessibile o della frammentazione della griglia.

Ecco un'illustrazione di come un layout a tre colonne viene rappresentato internamente nel motore precedente, prima di utilizzare forbici, posizionamento e colla (abbiamo un'altezza specifica, in modo che si adattino solo quattro righe, ma c'è spazio in eccesso nella parte inferiore):

La rappresentazione interna come un&#39;unica colonna con i punti di impaginazione in cui vengono spezzati i contenuti e la rappresentazione sullo schermo come tre colonne.

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

Ecco un esempio semplice con text-shadow:

Il motore precedente non gestisce bene questi problemi:

Ombre del testo ritagliato inserite nella seconda colonna.

Riesci a vedere come l'ombra del testo della linea della prima colonna è tagliata e invece posizionata nella parte superiore della seconda colonna? Questo perché il motore di layout legacy non comprende la frammentazione.

Dovrebbe avere il seguente aspetto (ed è come si vede con NG):

Due colonne di testo con le ombre visualizzate correttamente.

Ora rendiamo tutto un po' più complicato, con trasformazioni e box-shadow. Nota che nel motore precedente sono presenti tagli errati ed evidenziazioni delle colonne. Questo perché le trasformazioni dovrebbero essere applicate in base alle specifiche come effetto post-layout e post-frammentazione. Con la frammentazione LayoutNG entrambi funzionano correttamente. Ciò aumenta l'interoperabilità con Firefox, che ha avuto un buon supporto per la frammentazione da tempo con la maggior parte dei test in quest'area che passano lì.

Le caselle sono suddivise erroneamente tra due colonne.

Il motore precedente presenta anche problemi con i contenuti monolitici più alti. I contenuti sono monolitici se non possono essere suddivisi in più frammenti. Gli elementi con scorrimento extra sono monolitici, perché non ha senso per gli utenti scorrere in una regione non rettangolare. Riquadri e immagini sono altri esempi di contenuti monolitici. Esempio:

Se un contenuto monolitico è troppo alto per essere inserito in una colonna, il motore precedente lo suddivide brutalmente, generando un comportamento molto "interessante" quando si cerca di scorrere il contenitore scorrevole):

Invece di escludere la prima colonna (come avviene con la frammentazione del blocco LayoutNG):

ALT_TEXT_HERE

Il motore precedente supporta le interruzioni forzate. Ad esempio, <div style="break-before:page;"> inserirà un'interruzione di pagina prima dell'elemento DIV. Tuttavia, ha un supporto limitato per individuare le interruzioni non forzate ottimali. Supporta break-inside:avoid e orfani e vedove, ma non è supportato per evitare interruzioni tra i blocchi, se richiesto tramite break-before:avoid, ad esempio. Considera questo esempio:

Testo suddiviso in due colonne.

In questo caso, l'elemento #multicol ha spazio per 5 righe in ogni colonna (poiché è alto 100 px e l'altezza della riga è 20 px), quindi l'intero elemento #firstchild potrebbe essere contenuto nella prima colonna. Tuttavia, l'elemento di pari livello #secondchild presenta break-before:avoid, il che significa che i contenuti desiderano che non si interrompa tra loro. Poiché il valore di widows è 2, dobbiamo eseguire il push di due righe di #firstchild nella seconda colonna per rispettare tutte le richieste di interruzione delle interruzioni. Chromium è il primo motore del browser che supporta pienamente questa combinazione di funzionalità.

Come funziona la frammentazione NG

Il motore di layout NG generalmente stende il documento attraversando la struttura ad albero di caselle CSS in profondità. Quando sono disposti tutti i discendenti di un nodo, il suo layout può essere completato generando un NGPhysicalFragment e tornando all'algoritmo di layout padre. Questo algoritmo aggiunge il frammento al suo elenco di frammenti figlio e, una volta completati tutti i figli, genera un frammento per se stesso contenente tutti i relativi frammenti figlio. Con questo metodo crea un albero di frammenti per l'intero documento. Tuttavia, si tratta di una semplificazione eccessiva: ad esempio, gli elementi posizionati fuori flusso dovranno apparire dal punto in cui sono presenti nell'albero DOM al blocco contenitore prima di poter essere disposti. Per semplicità, preferisco ignorare questi dettagli avanzati.

Insieme alla casella CSS stessa, LayoutNG fornisce uno spazio vincolato all'algoritmo di layout. In questo modo l'algoritmo fornisce informazioni come lo spazio disponibile per il layout, se è stato stabilito un nuovo contesto di formattazione e i risultati della compressione del margine intermedio dai contenuti precedenti. Lo spazio del vincolo conosce anche le dimensioni del blocco strutturate del frammento e l'attuale offset del blocco al suo interno. Indica dove interrompere.

Quando è coinvolta la frammentazione del blocco, il layout dei discendenti deve interrompersi in un'interruzione. Le cause degli errori sono l'esaurimento dello spazio nella pagina o nella colonna o un'interruzione forzata. Quindi produciamo frammenti per i nodi che abbiamo visitato e torniamo fino alla radice del contesto di frammentazione (il container multicol o, in caso di stampa, la radice del documento). Poi, alla radice del contesto di frammentazione, ci prepariamo a un nuovo frammentatore, per poi cadere di nuovo nell'albero, riprendendo da dove avevamo interrotto prima della rottura.

La struttura dei dati cruciale per fornire i mezzi per ripristinare il layout dopo un'interruzione è denominata NGBlockBreakToken. Contiene tutte le informazioni necessarie per ripristinare correttamente il layout nel frammentatore successivo. Un NGBlockBreakToken è associato a un nodo e forma una struttura ad albero NGBlockBreakToken, in modo che sia rappresentato ogni nodo che deve essere ripreso. Un NGBlockBreakToken è collegato al componente NGPhysicalBoxFragment generato per i nodi che si interrompono all'interno. I token di interruzione vengono propagati agli elementi principali, formando un albero di token di interruzione. Se dobbiamo interrompere prima di un nodo (invece che al suo interno), non verrà prodotto alcun frammento, ma il nodo padre deve comunque creare un token di interruzione "break-prima" per il nodo, in modo da poter iniziare a disporlo quando arriveremo alla stessa posizione nella struttura ad albero dei nodi nel frammentatore successivo.

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

La specifica contiene regole per ottimizzare le interruzioni non forzate e non è sempre consigliabile inserire un'interruzione nel punto esatto in cui esauriamo lo spazio. Ad esempio, esistono varie proprietà CSS, come break-before, che influiscono sulla scelta della posizione dell'interruzione. Pertanto, durante il layout, per implementare correttamente la sezione della specifica delle interruzioni non forzate, dobbiamo tenere traccia dei punti di interruzione potenzialmente validi. Questo record significa che possiamo tornare indietro e utilizzare l'ultimo punto di interruzione migliore trovato se esauriamo lo spazio in un punto in cui violeremmo le richieste di interruzione delle interruzioni (ad esempio, break-before:avoid o orphans:7). A ogni possibile punto di interruzione viene assegnato un punteggio, che va da "farlo solo come ultima alternativa" a "luogo perfetto in cui interrompere", con alcuni valori intermedi. Se una posizione di un'interruzione viene contrassegnata come "perfetta", significa che non ci saranno violazioni in caso di infrazione (e se otteniamo questo punteggio esattamente nel punto in cui esauriamo lo spazio, non c'è bisogno di cercare qualcosa di meglio). Se il punteggio è "last-resort", il punto di interruzione non è nemmeno valido, ma potremmo comunque spezzarlo se non troviamo niente di meglio, per evitare un overflow frammentario.

I punti di interruzione validi in genere si verificano solo tra sorelle (caselle di riga o blocchi) e non, ad esempio, tra un elemento principale e il primo elemento figlio (i punti di interruzione di classe C sono un'eccezione, ma non è necessario discuterli qui). Esiste un punto di interruzione valido, ad esempio prima di un fratello/sorella 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 rilevato finora in una struttura chiamata NGEarlyBreak. Un'interruzione anticipata è un possibile punto di interruzione prima, all'interno di un nodo a blocchi o prima di una riga (una linea del container a 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 modo profondo all'interno di qualcosa a cui abbiamo camminato prima nel momento in cui esauriamo lo spazio. Esempio:

In questo caso, esauriamo lo spazio poco prima di #second, ma presenta "break-before:avoid", che riceve un punteggio relativo alla posizione delle interruzioni di "violazione delle interruzioni". A quel punto abbiamo una catena NGEarlyBreak di "all'interno di #outer > all'interno di #middle > all'interno di #inner > prima di "riga 3"', con la dicitura "perfect", quindi dovremmo interromperci. Dobbiamo quindi restituire il layout ed eseguirlo di nuovo dall'inizio di #outer (e questa volta superare il NGEarlyBreak che abbiamo trovato), in modo da poter rompere prima della "riga 3" in #inner. (Facciamo un'interruzione prima della "riga 3", in modo che le restanti 4 righe finiscano nel frammentatore successivo e per rispettare widows:4.)

L'algoritmo è progettato per violare sempre il miglior punto di interruzione possibile, come definito nella spec, inserendo le regole nell'ordine corretto, se non tutte possono essere soddisfatte. Tieni presente che dobbiamo modificare il layout al massimo una volta per flusso di frammentazione. Al momento del secondo passaggio del layout, la migliore posizione di interruzione è già stata trasmessa agli algoritmi di layout, ovvero la posizione dell'interruzione scoperta nel primo passaggio del layout e fornita come parte dell'output del layout in quel round. Nel secondo passaggio del layout, non ci stendiamo finché lo spazio non si esaurisce; anzi, non ci aspettiamo che esaurisca lo spazio (che sarebbe in realtà un errore), perché ci è stato fornito un posto super dolce (anche se dolce come disponibile) in cui inserire una pausa anticipata, per evitare di violare inutilmente eventuali regole che infrangeranno le regole. Quindi ci spingiamo a quel punto e ci spingiamo.

A tal proposito, a volte dobbiamo violare alcune delle richieste di eliminazione delle interruzioni, se questo contribuisce a evitare un overflow frammentario. Esempio:

Qui esauriamo lo spazio appena prima di #second, ma c'è "break-before:avoid". Ciò si traduce in "violazione delle interruzioni ", come nell'ultimo esempio. È disponibile anche una NGEarlyBreak con l'indicazione "orfani e vedove in violazione" (all'interno di #first > prima della "riga 2"), che comunque non è perfetta, ma è meglio che "violare le interruzioni". Quindi faremo un'interruzione prima della "riga 2", violando la richiesta relativa ad orfani / vedove. La specifica descrive questo aspetto nella sezione 4.4. Interruzioni non forzate, in cui definisce quali regole di violazione vengono ignorate per prime se non abbiamo punti di interruzione sufficienti per evitare un overflow frammentario.

Riepilogo

Il principale obiettivo funzionale del progetto di frammentazione dei blocchi LayoutNG era fornire l'implementazione a supporto dell'architettura LayoutNG di tutto ciò che il motore precedente supportava il meno possibile altro, a parte le correzioni di bug. L'eccezione principale in questo caso è il supporto migliore per evitare interruzioni (ad esempio break-before:avoid), perché questa è una parte fondamentale del motore di frammentazione, quindi doveva essere presente fin dall'inizio, poiché la sua aggiunta in seguito significherebbe un'altra riscrittura.

Ora che la frammentazione dei blocchi con LayoutNG è completa, possiamo iniziare a lavorare all'aggiunta di nuove funzionalità, come il supporto di formati pagina misti durante la stampa, @page caselle a margine per la stampa, box-decoration-break:clone e altro ancora. E come per LayoutNG in generale, ci aspettiamo che la percentuale di bug e il carico di manutenzione del nuovo sistema diminuiscano sostanzialmente nel tempo.

Grazie per l'attenzione.

Ringraziamenti