Blog in live streaming potenziato - Suddivisione del codice

Nel nostro live streaming Supercharged più recente abbiamo implementato la suddivisione del codice e lo smembramento in base al percorso. Con HTTP/2 e i moduli ES6 nativi, queste tecniche diventeranno essenziali per consentire il caricamento e la memorizzazione nella cache efficienti delle risorse dello script.

Suggerimenti e trucchi vari in questa puntata

  • asyncFunction().catch() con error.stack: 9:55
  • Moduli e attributo nomodule nei tag <script>: 7:30
  • promisify() nel nodo 8: 17:20

TL;DR

Come eseguire la suddivisione del codice tramite il chunking basato su route:

  1. Ottieni un elenco dei tuoi punti di contatto.
  2. Estrai le dipendenze dei moduli di tutti questi punti di contatto.
  3. Trova le dipendenze condivise tra tutti i punti di contatto.
  4. Raggruppa le dipendenze condivise.
  5. Riscrivere i punti di contatto.

Suddivisione del codice e suddivisione in blocchi basata su route

La suddivisione del codice e lo chunking basato su route sono strettamente correlati e spesso vengono utilizzati in modo intercambiabile. Ciò ha causato una certa confusione. Cerchiamo di chiarire:

  • Suddivisione del codice: la suddivisione del codice è il processo di suddivisione del codice in più bundle. Se non invii un unico bundle grande con tutto il codice JavaScript al client, stai eseguendo la suddivisione del codice. Un modo specifico per suddividere il codice è utilizzare lo chunking basato sui route.
  • Chunking basato su route: il chunking basato su route crea pacchetti correlati ai route dell'app. Analizzando i percorsi e le relative dipendenze, possiamo modificare i moduli che fanno parte di ciascun bundle.

Perché eseguire la suddivisione del codice?

Moduli allentati

Con i moduli ES6 nativi, ogni modulo JavaScript può importare le proprie dipendenze. Quando il browser riceve un modulo, tutte le istruzioni import attiveranno ulteriori acquisizioni per ottenere i moduli necessari per eseguire il codice. Tuttavia, tutti questi moduli possono avere dipendenze proprie. Il pericolo è che il browser finisca con una serie di recuperi che durano per più viaggi prima che il codice possa finalmente essere eseguito.

Raggruppamento

Il bundling, ovvero l'inserimento in linea di tutti i moduli in un unico bundle, garantisce che il browser disponga di tutto il codice necessario dopo un solo round trip e possa iniziare a eseguire il codice più rapidamente. Tuttavia, questo costringe l'utente a scaricare molto codice non necessario, quindi la larghezza di banda e il tempo vengono sprecati. Inoltre, ogni modifica a uno dei nostri moduli originali comporterà una modifica del bundle, invalidando qualsiasi versione memorizzata nella cache del bundle. Gli utenti dovranno riscaricare l'intero pacchetto.

Suddivisione del codice

La suddivisione del codice è la via di mezzo. Siamo disposti a investire in ulteriori viaggi di andata e ritorno per ottenere un'efficienza della rete scaricando solo ciò di cui abbiamo bisogno e una migliore efficienza della memorizzazione nella cache riducendo notevolmente il numero di moduli per bundle. Se il raggruppamento viene eseguito correttamente, il numero totale di viaggi in andata e ritorno sarà molto inferiore rispetto ai moduli singoli. Infine, potremmo utilizzare meccanismo di precaricamento come link[rel=preload] per risparmiare ulteriore tempo di tripletta, se necessario.

Passaggio 1: ottieni un elenco dei tuoi punti di contatto

Questo è solo uno dei tanti approcci, ma nella puntata abbiamo analizzato il sitemap.xml del sito web per ottenere i punti di accesso al nostro sito. In genere viene utilizzato un file JSON dedicato che elenca tutti i punti di contatto.

Utilizzo di Babel per l'elaborazione di JavaScript

Babel viene comunemente utilizzato per la "traspilazione": utilizza il codice JavaScript all'avanguardia e lo trasforma in una versione precedente di JavaScript in modo che un maggior numero di browser possa eseguirlo. Il primo passaggio consiste nell'analizzare il nuovo codice JavaScript con un parser (Babel utilizza Babylon) che trasforma il codice in una cosiddetta "Abstract Syntax Tree" (AST). Una volta generato l'AST, una serie di plug-in lo analizza e lo manipola.

Faremo un uso intensivo di Babel per rilevare (e successivamente manipolare) le importazioni di un modulo JavaScript. Potresti essere tentato di ricorrere alle espressioni regolari, ma queste non sono abbastanza efficaci per analizzare correttamente un linguaggio e sono difficili da gestire. Fare affidamento su strumenti collaudati come Babel ti farà risparmiare molti problemi.

Ecco un semplice esempio di esecuzione di Babel con un plug-in personalizzato:

const plugin = {
  visitor: {
    ImportDeclaration(decl) {
      /* ... */
    }
  }
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});

Un plug-in può fornire un oggetto visitor. Il visitatore contiene una funzione per qualsiasi tipo di nodo che il plug-in vuole gestire. Quando viene rilevato un nodo di questo tipo durante il percorso dell'AST, la funzione corrispondente nell'oggetto visitor verrà richiamata con il nodo come parametro. Nell'esempio riportato sopra, il metodo ImportDeclaration() verrà chiamato per ogni dichiarazione import nel file. Per avere un'idea più chiara dei tipi di nodi e dell'AST, dai un'occhiata a astexplorer.net.

Passaggio 2: estrai le dipendenze del modulo

Per creare l'albero delle dipendenze di un modulo, analizzeremo il modulo e creeremo un elenco di tutti i moduli che importa. Dobbiamo anche analizzare queste dipendenze, poiché a loro volta potrebbero avere altre dipendenze. Un caso classico di ricorsione.

async function buildDependencyTree(file) {
  let code = await readFile(file);
  code = code.toString('utf-8');

  // `dep` will collect all dependencies of `file`
  let dep = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // Recursion: Push an array of the dependency’s dependencies onto the list
        dep.push((async function() {
          return await buildDependencyTree(`./app/${importedFile}`);
        })());
        // Push the dependency itself onto the list
        dep.push(importedFile);
      }
    }
  }
  // Run the plugin
  babel.transform(code, {plugins: [plugin]});
  // Wait for all promises to resolve and then flatten the array
  return flatten(await Promise.all(dep));
}

Passaggio 3: trova le dipendenze condivise tra tutti i punti di contatto

Poiché abbiamo un insieme di alberi di dipendenza, ovvero una foresta di dipendenze, possiamo trovare le dipendenze condivise cercando i nodi che compaiono in ogni albero. Applichiamo l'appiattimento e la deduplica alla foresta e applichiamo un filtro per conservare solo gli elementi che compaiono in tutti gli alberi.

function findCommonDeps(depTrees) {
  const depSet = new Set();
  // Flatten
  depTrees.forEach(depTree => {
    depTree.forEach(dep => depSet.add(dep));
  });
  // Filter
  return Array.from(depSet)
    .filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}

Passaggio 4: raggruppa le dipendenze condivise

Per raggruppare il nostro insieme di dipendenze condivise, potremmo semplicemente concatenare tutti i file del modulo. Con questo approccio si verificano due problemi: il primo è che il bundle conterrà ancora istruzioni import che indurranno il browser a tentare di recuperare le risorse. Il secondo problema è che le dipendenze delle dipendenze non sono state raggruppate. Dato che l'abbiamo già fatto, scriveremo un altro plug-in babel.

Il codice è abbastanza simile al nostro primo plug-in, ma anziché estrarre semplicemente le importazioni, le rimuoveremo e inseriremo una versione in bundle del file importato:

async function bundle(oldCode) {
  // `newCode` will be filled with code fragments that eventually form the bundle.
  let newCode = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        newCode.push((async function() {
          // Bundle the imported file and add it to the output.
          return await bundle(await readFile(`./app/${importedFile}`));
        })());
        // Remove the import declaration from the AST.
        decl.remove();
      }
    }
  };
  // Save the stringified, transformed AST. This code is the same as `oldCode`
  // but without any import statements.
  const {code} = babel.transform(oldCode, {plugins: [plugin]});
  newCode.push(code);
  // `newCode` contains all the bundled dependencies as well as the
  // import-less version of the code itself. Concatenate to generate the code
  // for the bundle.
  return flatten(await Promise.all(newCode)).join('\n');
}

Passaggio 5: riscrivi i punti di contatto

Per l'ultimo passaggio scriveremo un altro plug-in Babel. Il suo compito è rimuovere tutte le importazioni di moduli presenti nel bundle condiviso.

async function rewrite(section, sharedBundle) {
  let oldCode = await readFile(`./app/static/${section}.js`);
  oldCode = oldCode.toString('utf-8');
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // If this import statement imports a file that is in the shared bundle, remove it.
        if(sharedBundle.includes(importedFile))
          decl.remove();
      }
    }
  };
  let {code} = babel.transform(oldCode, {plugins: [plugin]});
  // Prepend an import statement for the shared bundle.
  code = `import '/static/_shared.js';\n${code}`;
  await writeFile(`./app/static/_${section}.js`, code);
}

Fine

È stato un bel viaggio, vero? Ricorda che il nostro obiettivo per questa puntata era spiegare e demistificare la suddivisione del codice. Il risultato funziona, ma è specifico per il nostro sito dimostrativo e non funzionerà nel caso generico. Per la produzione, ti consiglio di utilizzare strumenti consolidati come WebPack, RollUp e così via.

Puoi trovare il nostro codice nel repository GitHub.

Alla prossima occasione.