Usare la memorizzazione nella cache a lungo termine

In che modo webpack è utile per la memorizzazione nella cache degli asset

Dopo aver ottimizzato le dimensioni dell'app, la prossima cosa che migliora il tempo di caricamento dell'app è la memorizzazione nella cache. Utilizzalo per conservare parti dell'app sul client ed evitare di scaricarle di nuovo ogni volta.

Utilizzare il controllo della versione del bundle e le intestazioni della cache

L'approccio comune alla memorizzazione nella cache è:

  1. di memorizzare nella cache un file per un periodo di tempo molto lungo (ad es. un anno):

    # Server header
    Cache-Control: max-age=31536000
    

    Se non sai cosa fa Cache-Control, consulta l'eccellente post di Jake Archibald sulle best practice per la memorizzazione nella cache.

  2. e rinomina il file quando viene modificato per forzare il nuovo download:

    <!-- Before the change -->
    <script src="./index-v15.js"></script>
    
    <!-- After the change -->
    <script src="./index-v16.js"></script>
    

Questo approccio indica al browser di scaricare il file JS, memorizzarlo nella cache e utilizzare la copia memorizzata nella cache. Il browser accederà alla rete solo se il nome del file cambia (o se passa un anno).

Con webpack, fai lo stesso, ma anziché un numero di versione, specifichi l'hash del file. Per includere l'hash nel nome del file, utilizza [chunkhash]:

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

Se hai bisogno del nome del file per inviarlo al cliente, utilizza HtmlWebpackPlugin o WebpackManifestPlugin.

HtmlWebpackPlugin è un approccio semplice, ma meno flessibile. Durante la compilazione, questo plug-in genera un file HTML che include tutte le risorse compilate. Se la logica del server non è complessa, dovrebbe essere sufficiente:

<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>

WebpackManifestPlugin è un approccio più flessibile, utile se hai una parte del server complessa. Durante la compilazione, viene generato un file JSON con una mappatura tra i nomi dei file senza hash e i nomi dei file con hash. Utilizza questo JSON sul server per scoprire con quale file lavorare:

// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}

Per approfondire

Estrai le dipendenze e il runtime in un file separato

Dipendenze

Le dipendenze dell'app tendono a cambiare meno spesso rispetto al codice dell'app effettivo. Se li sposti in un file separato, il browser potrà memorizzarli nella cache separatamente e non li scaricherà di nuovo ogni volta che il codice dell'app cambia.

Per estrarre le dipendenze in un chunk separato, svolgi tre passaggi:

  1. Sostituisci il nome file di output con [name].[chunkname].js:

    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js'
      }
    };
    

    Quando webpack compila l'app, sostituisce [name] con il nome di uno chunk. Se non aggiungiamo la parte [name], dovremo differenziare i chunk in base al loro hash, il che è piuttosto difficile.

  2. Converti il campo entry in un oggetto:

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js'
      }
    };
    

    In questo snippet, "main" è il nome di un chunk. Questo nome verrà sostituito al posto di [name] del passaggio 1.

    A questo punto, se crei l'app, questo blocco includerà l'intero codice dell'app, come se non avessimo eseguito questi passaggi. Ma tra un attimo cambierà.

  3. In webpack 4, aggiungi l'opzione optimization.splitChunks.chunks: 'all' alla configurazione di webpack:

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all'
        }
      }
    };
    

    Questa opzione consente la suddivisione del codice in modo intelligente. Con questo, webpack estrae il codice del fornitore se supera i 30 kB (prima della minimizzazione e di gzip). Verrà estratto anche il codice comune, utile se la compilazione produce più bundle (ad es. se dividi l'app in route).

    In webpack 3, aggiungi CommonsChunkPlugin:

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
        // A name of the chunk that will include the dependencies.
        // This name is substituted in place of [name] from step 1
        name: 'vendor',
    
        // A function that determines which modules to include into this chunk
        minChunks: module => module.context && module.context.includes('node_modules'),
        })
      ]
    };
    

    Questo plug-in prende tutti i moduli i cui percorsi includono node_modules e li sposta in un file separato chiamato vendor.[chunkhash].js.

Dopo queste modifiche, ogni compilazione genererà due file anziché uno: main.[chunkhash].js e vendor.[chunkhash].js (vendors~main.[chunkhash].js per webpack 4). Nel caso di webpack 4, il bundle del fornitore potrebbe non essere generato se le dipendenze sono ridotte e non c'è problema:

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                        Asset      Size  Chunks             Chunk Names
 ./main.00bab6fd3100008a42b0.js   82 kB       0  [emitted]  main
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

Il browser memorizza in cache questi file separatamente e scarica di nuovo solo il codice che cambia.

Codice di runtime di Webpack

Purtroppo, non è sufficiente estrarre solo il codice fornitore. Se provi a cambiare qualcosa nel codice dell'app:

// index.js
…
…

// E.g. add this:
console.log('Wat');

noterai che anche l'hash vendor cambia:

                           Asset   Size  Chunks             Chunk Names
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

                            Asset   Size  Chunks             Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js  47 kB       1  [emitted]  vendor

Questo accade perché il bundle webpack, oltre al codice dei moduli, ha un ambiente di runtime, ovvero un piccolo frammento di codice che gestisce l'esecuzione del modulo. Quando dividi il codice in più file, questo frammento di codice inizia a includere una mappatura tra gli ID chunk e i file corrispondenti:

// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
    "0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";

Webpack include questo runtime nell'ultimo chunk generato, ovvero vendor nel nostro caso. E ogni volta che un chunk cambia, cambia anche questo pezzo di codice, provocando la modifica dell'intero chunk vendor.

Per risolvere il problema, spostiamo il runtime in un file separato. In webpack 4,questo viene ottenuto attivando l'opzione optimization.runtimeChunk:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true
  }
};

In webpack 3,crea un chunk vuoto aggiuntivo con CommonsChunkPlugin:

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => module.context && module.context.includes('node_modules')
    }),
    // This plugin must come after the vendor one (because webpack
    // includes runtime into the last chunk)
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
      // minChunks: Infinity means that no app modules
      // will be included into this chunk
      minChunks: Infinity
    })
  ]
};

Dopo queste modifiche, ogni build genererà tre file:

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

Includili in index.html nell'ordine inverso e il gioco è fatto:

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>

Per approfondire

Esegui il runtime webpack in linea per risparmiare una richiesta HTTP aggiuntiva

Per migliorare ulteriormente la situazione, prova a eseguire l'inlining del runtime webpack nella risposta HTML. Ad esempio, invece di:

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>

procedi nel seguente modo:

<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>

Il runtime è piccolo e l'inserimento in linea ti consente di risparmiare una richiesta HTTP (molto importante con HTTP/1; meno importante con HTTP/2, ma potrebbe comunque avere un effetto).

Ecco come fare.

Se generi HTML con HtmlWebpackPlugin

Se utilizzi il plug-in HtmlWebpackPlugin per generare un file HTML, il plug-in InlineSourcePlugin è tutto ciò che ti serve:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      inlineSource: 'runtime~.+\\.js',
    }),
    new InlineSourcePlugin()
  ]
};

Se generi HTML utilizzando una logica del server personalizzata

Con webpack 4:

  1. Aggiungi WebpackManifestPlugin per conoscere il nome generato del chunk di runtime:

    // webpack.config.js (for webpack 4)
    const ManifestPlugin = require('webpack-manifest-plugin');
    
    module.exports = {
      plugins: [
        new ManifestPlugin()
      ]
    };
    

    Una compilazione con questo plug-in crea un file simile al seguente:

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. Inserisci in linea i contenuti del chunk di runtime in modo pratico. Ad esempio, con Node.js ed Express:

    // server.js
    const fs = require('fs');
    const manifest = require('./manifest.json');
    const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

Oppure con webpack 3:

  1. Rendi statico il nome del runtime specificando filename:

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. Inserisci i contenuti runtime.js in linea in modo pratico. Ad esempio, con Node.js ed Express:

    // server.js
    const fs = require('fs');
    const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

Codice con caricamento differito che non ti serve al momento

A volte, una pagina ha parti più o meno importanti:

  • Se carichi la pagina di un video su YouTube, ti interessa di più il video che i commenti. In questo caso, il video è più importante dei commenti.
  • Se apri un articolo su un sito di notizie, ti interessa di più il testo dell'articolo che gli annunci. In questo caso, il testo è più importante degli annunci.

In questi casi, migliora le prestazioni di caricamento iniziale scaricando prima solo gli elementi più importanti e caricando in modo lazy le parti rimanenti in un secondo momento. A tale scopo, utilizza la funzione import() e la suddivisione del codice:

// videoPlayer.js
export function renderVideoPlayer() { … }

// comments.js
export function renderComments() { … }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});

import() specifica che vuoi caricare un modulo specifico in modo dinamico. Quando webpack vede import('./module.js'), sposta questo modulo in un frammento separato:

$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.f7e53d8e13e9a2745d6d.js    60 kB       1  [emitted]  main
 ./vendor.4f14b6326a80f4752a98.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

e lo scarica solo quando l'esecuzione raggiunge la funzione import().

In questo modo, il bundle main sarà più piccolo e il tempo di caricamento iniziale migliorerà. Inoltre, migliorerà la memorizzazione nella cache: se modifichi il codice nel chunk principale, il chunk dei commenti non verrà interessato.

Per approfondire

Suddividi il codice in route e pagine

Se la tua app ha più route o pagine, ma esiste un solo file JS con il codice (un singolo chunk main), è probabile che tu stia pubblicando byte aggiuntivi su ogni richiesta. Ad esempio, quando un utente visita la home page del tuo sito:

Una home page di Web Fundamentals

non devono caricare il codice per il rendering di un articolo che si trova su un'altra pagina, ma lo faranno. Inoltre, se l'utente visita sempre solo la home page e apporti una modifica al codice dell'articolo, webpack invaliderà l'intero bundle e l'utente dovrà scaricare di nuovo l'intera app.

Se dividiamo l'app in pagine (o route, se si tratta di un'app a pagina singola), l'utente scarica solo il codice pertinente. Inoltre, il browser memorizza meglio nella cache il codice dell'app: se modifichi il codice della home page, webpack invaliderà solo il corrispondente frammento.

Per le app a pagina singola

Per suddividere le app a pagina singola in base ai percorsi, utilizza import() (consulta la sezione "Codice di caricamento differito che non ti serve al momento"). Se utilizzi un framework, potrebbe avere già una soluzione per questo problema:

Per le app tradizionali con più pagine

Per suddividere le app tradizionali per pagine, utilizza i punti di entry di webpack. Se la tua app ha tre tipi di pagine: la home page, la pagina dell'articolo e la pagina dell'account utente, deve avere tre voci:

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

Per ogni file di entrata, webpack creerà un albero delle dipendenze separato e genererà un bundle che include solo i moduli utilizzati da quella voce:

$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime

Pertanto, se solo la pagina dell'articolo utilizza Lodash, i bundle home e profile non lo includeranno e l'utente non dovrà scaricare questa libreria quando visita la home page.

Tuttavia, gli alberi delle dipendenze separati hanno i loro svantaggi. Se due punti di contatto utilizzano Lodash e non hai spostato le dipendenze in un bundle del fornitore, entrambi i punti di contatto includeranno una copia di Lodash. Per risolvere il problema, in webpack 4,aggiungi l'opzione optimization.splitChunks.chunks: 'all' alla configurazione di webpack:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

Questa opzione consente la suddivisione del codice in modo intelligente. Con questa opzione, webpack cercherà automaticamente il codice comune e lo estrarrà in file separati.

In alternativa, in webpack 3,utilizza CommonsChunkPlugin per spostare le dipendenze comuni in un nuovo file specificato:

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

Non esitare a modificare il valore minChunks per trovare quello migliore. In genere, è preferibile mantenere il valore ridotto, ma aumentarlo se il numero di chunk aumenta. Ad esempio, per 3 chunk, minChunks potrebbe essere 2, ma per 30 chunk potrebbe essere 8, perché se mantieni il valore 2, nel file comune verranno inseriti troppi moduli, gonfiandolo troppo.

Per approfondire

Rendere gli ID modulo più stabili

Durante la compilazione del codice, webpack assegna a ogni modulo un ID. In seguito, questi ID vengono utilizzati nei require() all'interno del bundle. In genere, gli ID vengono visualizzati nell'output della compilazione appena prima dei percorsi dei moduli:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

↓ Qui

[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module

Per impostazione predefinita, gli ID vengono calcolati utilizzando un contatore (ad es. il primo modulo ha l'ID 0, il secondo l'ID 1 e così via). Il problema è che quando aggiungi un nuovo modulo, questo potrebbe apparire nel mezzo dell'elenco dei moduli, cambiando tutti gli ID dei moduli successivi:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]

↓ Abbiamo aggiunto un nuovo modulo…

[4] ./webPlayer.js 24 kB {1} [built]

↓ Guarda cosa ha fatto. comments.js ora ha l'ID 5 anziché 4

[5] ./comments.js 58 kB {0} [built]

ads.js ora ha l'ID 6 anziché 5

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

In questo modo, vengono invalidati tutti i chunk che includono o dipendono da moduli con ID modificati, anche se il codice effettivo non è cambiato. Nel nostro caso, il chunk 0 (il chunk con comments.js) e il chunk main (il chunk con l'altro codice dell'app) vengono invalidati, mentre solo il chunk main avrebbe dovuto esserlo.

Per risolvere il problema, modifica il modo in cui vengono calcolati gli ID modulo utilizzando HashedModuleIdsPlugin. Sostituisce gli ID basati su contatori con hash dei percorsi dei moduli:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime

↓ Qui

[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module

Con questo approccio, l'ID di un modulo cambia solo se lo rinomini o lo sposti. I nuovi moduli non influiranno sugli ID di altri moduli.

Per attivare il plug-in, aggiungilo alla sezione plugins della configurazione:

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

Per approfondire

Riepilogo

  • Memorizza nella cache il bundle e distingui le versioni modificando il nome del bundle
  • Suddividi il bundle in codice dell'app, codice del fornitore e runtime
  • Inserire in linea il runtime per salvare una richiesta HTTP
  • Carica in modo lazy il codice non critico con import
  • Suddividi il codice per percorsi/pagine per evitare di caricare elementi non necessari