Passa al contenuto

Meccanismo di rendering

Come fa Vue a trasformare un template in un nodo del DOM? Come aggiorna questi nodi in modo efficiente? Cercheremo di fare luce su queste domande approfondendo il meccanismo di rendering interno di Vue.

Virtual DOM

Probabilmente hai sentito parlare del termine "virtual DOM", su cui si basa il sistema di rendering di Vue.

Il DOM virtuale (VDOM) è un concetto di programmazione in cui una rappresentazione ideale, o "virtuale", di un'interfaccia utente viene mantenuta in memoria e sincronizzata con il DOM "reale". Il concetto è stato introdotto da React ed è stato adottato da molti altri framework con diverse implementazioni, tra cui Vue.

Il DOM virtuale è più un modello che una tecnologia specifica, quindi non esiste un'implementazione canonica. Possiamo illustrare l'idea con un semplice esempio:

js
const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* altri vnode */
  ]
}

Qui, vnode è un oggetto JavaScript (un "nodo virtuale") che rappresenta un elemento <div>. Contiene tutte le informazioni necessarie per creare l'elemento vero e proprio. Contiene anche altri vnode figli, il che lo rende la root di un albero DOM virtuale.

Un runtime renderer può scorrere un albero DOM virtuale e costruire un albero DOM reale da esso. Questo processo è chiamato mount.

Se abbiamo due copie di alberi DOM virtuali, il render può anche scorrere e confrontare i due alberi, capire le differenze e applicare le modifiche al DOM reale. Questo processo è chiamato patch, noto anche come "diffing" o "reconciliation".

Il vantaggio principale del DOM virtuale è che offre allo sviluppatore la possibilità di creare, ispezionare e comporre programmaticamente le strutture dell'interfaccia utente desiderate in modo dichiarativo, lasciando al renderer la manipolazione diretta del DOM.

Render Pipeline

Ad alto livello, questo è ciò che accade quando viene montato un componente Vue:

  1. Compile: I modelli di Vue vengono compilati in render functions: funzioni che restituiscono alberi DOM virtuali. Questo passaggio può essere fatto sia in anticipo, tramite una fase di compilazione, o dinamicamente, utilizzando il compilatore a runtime.

  2. Mount: Il runtime renderer invoca le funzioni di rendering, percorre l'albero DOM virtuale restituito e crea i nodi DOM reali basandosi su di esso. Questo passaggio viene eseguito come un reactive effect, quindi tiene traccia di tutte le dipendenze reattive utilizzate.

  3. Patch: Quando una dipendenza utilizzata durante il mount cambia, l'effetto viene rieseguito. Questa volta, viene creato un nuovo albero DOM virtuale aggiornato. Il runtime renderer esamina il nuovo albero, lo confronta con quello vecchio e applica gli aggiornamenti necessari al DOM reale.

render pipeline

Templates vs. Render Functions

I templates di Vue sono compilati in funzioni di rendering del DOM virtuale. Vue fornisce anche delle API che consentono di saltare la fase di compilazione dei template e di creare direttamente le funzioni di rendering. Le funzioni di rendering sono più flessibili dei template quando si ha a che fare con una logica altamente dinamica, perché si può lavorare con i vnodes utilizzando tutta la potenza di JavaScript.

Perché Vue raccomanda i templates per impostazione predefinita? Ci sono diverse ragioni:

  1. I templates sono più vicini all'HTML vero e proprio. In questo modo è più facile riutilizzare gli snippet HTML esistenti, applicare le migliori pratiche di accessibilità, creare uno stile con i CSS, migliorando la comprensione e la modifica da parte dei designer.

  2. I template sono più facili da analizzare staticamente grazie alla loro sintassi. Questo consente al compilatore di template di Vue di applicare molte ottimizzazioni in fase di compilazione per migliorare le prestazioni del DOM virtuale (di cui parleremo più avanti).

In pratica, i templates sono sufficienti per la maggior parte dei casi d'uso nelle applicazioni. Le render functions sono tipicamente utilizzate solo nei componenti riutilizzabili che devono gestire una logica di rendering altamente dinamica. L'uso delle render functions è discusso più dettagliatamente in Render function e JSX.

Compiler-Informed Virtual DOM

L'implementazione del DOM virtuale in React e la maggior parte delle altre implementazioni del DOM virtuale sono puramente runtime: l'algoritmo di riconciliazione non può fare alcuna ipotesi sull'albero del DOM virtuale in arrivo, quindi deve attraversare completamente l'albero e confrontare le proprietà di ogni vnode per garantire la correttezza. Inoltre, anche se una parte dell'albero non cambia mai, a ogni re-render vengono sempre creati nuovi vnode, con conseguente inutile pressione sulla memoria. Questo è uno degli aspetti più criticati del DOM virtuale: il processo di riconciliazione, piuttosto brute-force, sacrifica l'efficienza in cambio di dichiaratività e correttezza.

Ma non deve essere necessariamente così. In Vue, il framework controlla sia il compilatore che il runtime. Questo ci permette di implementare molte ottimizzazioni in fase di compilazione che solo un renderer strettamente accoppiato può sfruttare. Il compilatore può analizzare staticamente il template e lasciare suggerimenti nel codice generato, in modo che il runtime possa prendere delle scorciatoie quando possibile. Allo stesso tempo, conserviamo la possibilità per l'utente di scendere al livello della funzione di rendering per un controllo più diretto nei casi limite. Chiamiamo questo approccio ibrido Compiler-Informed Virtual DOM.

Di seguito, discuteremo alcune importanti ottimizzazioni effettuate dal compilatore di template di Vue per migliorare le prestazioni del DOM virtuale in fase di esecuzione.

Hoisting statico

Molto spesso ci sono parti di un template che non contengono binding dinamici:

template
<div>
  <div>foo</div> <!-- hoisted -->
  <div>bar</div> <!-- hoisted -->
  <div>{{ dynamic }}</div>
</div>

Ispeziona in Template Explorer

I div foo e bar sono statici: ricreare i vnode e confrontarli a ogni rendering non è necessario. Il compilatore di Vue sposta automaticamente le chiamate alla creazione dei vnode dalla funzione di rendering e riutilizza gli stessi vnode a ogni rendering. Il renderer è anche in grado di evitare completamente la loro comparazione quando si accorge che il vecchio vnode e il nuovo vnode sono gli stessi.

Inoltre, quando ci sono abbastanza elementi statici consecutivi, essi saranno condensati in un singolo "vnode statico" che contiene la stringa HTML semplice per tutti questi nodi (Esempio). Questi vnode statici vengono montati impostando direttamente innerHTML. Inoltre, al momento del montaggio iniziale, vengono memorizzati nella cache i nodi DOM corrispondenti: se lo stesso contenuto viene riutilizzato in altre parti dell'applicazione, i nuovi nodi DOM vengono creati usando il metodo nativo cloneNode(), che è estremamente efficiente.

Patch Flags

Per un singolo elemento con binding dinamici, possiamo dedurre molte informazioni in fase di compilazione:

template
<!-- solo binding della classe -->
<div :class="{ active }"></div>

<!-- solo binfing di id e value -->
<input :id="id" :value="value">

<!-- solo testo figli -->
<div>{{ dynamic }}</div>

Ispeziona in Template Explorer

Quando genera il codice della funzione di rendering per questi elementi, Vue codifica il tipo di aggiornamento di cui ciascuno di essi ha bisogno direttamente nella chiamata di creazione del vnode:

js
createElementVNode("div", {
  class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)

L'ultimo argomento, 2, è un patch flag. Un elemento può avere più patch flags, che verranno unite in un unico numero. Il runtime renderer può quindi controllare i flag usando bitwise operations per determinare se deve fare un certo lavoro:

js
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
  // aggiorna la classe dell'elemento
}

I controlli bitwise sono estremamente veloci. Con i patch flags, Vue è in grado di fare il lavoro minimo necessario quando si aggiornano elementi con legami dinamici.

Vue codifica anche il tipo di figli che ha un vnode. Ad esempio, un template che ha più root nodes viene rappresentato come un frammento. Nella maggior parte dei casi, sappiamo con certezza che l'ordine di questi root nodes non cambierà mai, quindi, questa informazione può essere fornita al runtime come flag patch:

js
export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* figli */
  ], 64 /* STABLE_FRAGMENT */))
}

Il runtime può quindi saltare completamente la riconciliazione dell'ordine dei figli per il frammento root.

Tree Flattening

Dando un'altra occhiata al codice generato dall'esempio precedente, si noterà che la root dell'albero del DOM virtuale restituito viene creata usando una chiamata speciale createElementBlock():

js
export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* figli */
  ], 64 /* STABLE_FRAGMENT */))
}

Concettualmente, un "block" è una parte del template che ha una struttura interna stabile. In questo caso, l'intero template ha un unico blocco, perché non contiene direttive strutturali come v-if e v-for.

Ogni blocco tiene traccia di tutti i nodi discendenti (non solo i figli diretti) che hanno la flag patch. Per esempio:

template
<div> <!-- blocco root -->
  <div>...</div>         <!-- non tracciato -->
  <div :id="id"></div>   <!-- tracciato -->
  <div>                  <!-- non tracciato -->
    <div>{{ bar }}</div> <!-- tracciato -->
  </div>
</div>

Il risultato è un array appiattito che contiene solo i nodi discendenti dinamici:

div (blocco root)
- div con :id binding
- div con {{ bar }} binding

Quando questo componente deve eseguire un nuovo rendering, deve attraversare solo l'albero appiattito invece dell'albero completo. Questa operazione è chiamata Tree flattering e riduce notevolmente il numero di nodi che devono essere attraversati durante la riconciliazione del DOM virtuale. Tutte le parti statiche del template vengono di fatto saltate.

Le direttive v-if e v-for creeranno un nuovo block node:

template
<div> <!-- blocco root -->
  <div>
    <div v-if> <!-- blocco if -->
      ...
    <div>
  </div>
</div>

Un blocco figlio viene tracciato all'interno dell'array di discendenti dinamici del blocco genitore. In questo modo si mantiene una struttura stabile per il blocco genitore.

Impatto sulla SSR Hydration

Sia i patch flags che l'appiattimento dell'albero migliorano notevolmente le prestazioni di Vue SSR Hydration:

  • L'idratazione di un singolo elemento può seguire percorsi veloci in base al patch flag del vnode corrispondente.

  • Solo i block node e i loro discendenti dinamici devono essere attraversati durante l'idratazione, ottenendo di fatto un'idratazione parziale a livello di template.

Meccanismo di rendering has loaded