Una forma de mejorar el rendimiento de un sitio web es aprovechar el caché del navegador, de manera que se sirvan de la memoria de la computadora o dispositivo los archivos que cambian muy poco (como imágenes, javascript y css) y que el navegador sólo tenga que descargar los archivos que hayan cambiado.

A continuación explicaré paso a paso cómo separar el javascript propio del de los vendors, del de librerías de terceros con Webpack.

CONFIGURACIÓN

Vamos a iniciar nuestro proyecto de ejemplo usando la consola de comandos y npm. Creamos una carpeta dynamic-code-splitting y una vez allí ejecutamos

npm init -y

Esto nos crea un archivo package.json que define el paquete de nuestro proyecto.

Algo similar a:

/package.json


{
  "name": "dynamic-code-splitting",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

A continuación vamos a crear una carpeta src donde colocaremos los scripts que conforman nuestra aplicación o sitio y dentro crearemos un archivo index.js.

/src/index.js

console.log('Hola mundo');

Ya que usaremos sintaxis de javascript es6, incluiremos babel para compilar nuestro código para que los navegadores puedan correrlo sin problemas.

A continuación instalaremos las dependencias como Webpack y Babel.

npm install --save-dev webpack@2.7.0 babel-cli babel-core babel-loader

Nótese que instalamos específicamente la versión 2.7.0 de Webpack.
Para saber qué versiones están disponibiles de un paquete podemos usar el comando

npm view webpack versions --json

Para este tutorial instalaremos los plugins de babel que trae el preset es2015 para convertir nuestro javascript con algunas de las nuevas funcionalidades a una versión de javascript que los navegadores entiendan.

npm install --dev babel-preset-es2015

Ahora procedemos a crear un archivo webpack.config.js en la raíz de nuestro proyecto, que será el que webpack ejecutará por defecto.

/webpack.config.js


const path      = require('path')
const webpack   = require('webpack')

module.exports = {
    context: path.resolve(__dirname, 'src'),
    entry: {
        app: './index.js'
    },
    output: {
        path:       path.resolve(__dirname, 'dist'),
        filename:   '[name].js'
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader'
            }
        ]
    }
}

Para probar nuestra configuración ejecutamos webpack desde la consola de comandos en la raíz de nuestro proyecto:

node ./node_modules/.bin/webpack

Esto nos creará una carpeta dist y dentro un archivo app.js con el contenido de /src/index.js compilado con babel.

Para corroborar que está funcionando todo vamos a ejecutar nuestro archivo compilado. Nuevamente, abrimos la consola de comandos en la raíz del proyecto y ejecutamos:

node dist/app.js

Y deberíamos ver nuestro mensaje “Hola mundo”.

¿Cómo separar el código con Webpack?

Separar el código de terceros, llámense vendors, como son jQuery, React, Moment u otros nos entrega algunos beneficios para mejorar el rendimiento de un sitio web o aplicación web porque nos permite guardar en caché por mayor tiempo un recurso que es poco probable que cambie y que puede ser de mayor tamaño, del código propio de la aplicación que estamos escribiendo, que es muy probable que reciba constantes cambios.

Por ejemplo, podremos guardar en caché un archivo vendors.js por un mes, mientras que nuestro archivo app.js se guarda por una semana.

Antes de explicar cómo se realiza dicha separación en Webpack, vamos a preparar el código de ejemplo con ayuda de la librería momentjs, la cual nos permite formatear, validar y mostrar fechas en javascript.

Preparando nuestro proyecto:

Instalamos desde la consola las librerías moment

npm install --save-dev moment

A su vez, instalaremos la librería rimraf para limpiar nuestra carpeta dist cada vez que compilamos nuestra aplicación.

npm install --save-dev rimraf

Modificamos nuestro ejemplo para mostrar la fecha actual:

/src/index.js

import moment from 'moment'

console.log('Today: ', moment().format('MMM Do YYYY'));

Agregaremos algunos scripts al archivo package.json para ahorrarnos varios comandos y facilitarnos el trabajo de compilar nuestro código:

/package.json (sólo estamos mostrando los scripts)


"scripts": {
    "clean": "rimraf dist/**",
    "prebuild": "npm run clean",
    "build": "webpack"
  },

Nuestros scripts están definidos de manera que al ejecutar el script build, se ejecuta antes el script clean, el cual elimina todos los archivos dentro de la carpeta dist.

Nota: npm corre previamente cualquier script con un prefijo “pre” y el script a ejecutar. De manera que al ejecutar

npm run build

se ejecutará antes el script prebuild si existe.

Configurando Webpack para separar el código:

Para separar el código Webpack incluye el plugin CommonsChunkPlugins, el cual genera un “pedazo de código” o chunk con el código compartido en las diferentes entradas definidas en nuestra configuración.

Separaremos la librería moment en una entrada distinta llamada vendor.
Luego configuramos el plugin CommonsChunkPlugin para generar un archivo vendor.bundle.js con los scripts definidos en la entrada vendor:

/webpack.config.js


const path      = require('path')
const webpack   = require('webpack')

module.exports = {
    context: path.resolve(__dirname, 'src'),
    entry: {
        app: './index.js',
        vendor: [
            'moment'
        ]
    },
    output: {
        path:           path.resolve(__dirname, 'dist'),
        filename:       '[name].js'
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader'
            }
        ]
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name:       'vendor'
        })
    ]
}

Nótese que en la configuración de salida agregamos la propiedad chunkFilename, para definir el nombre de los chunks.

Además, definimos los nombres haciendo uso de los comodines [name] y [chunkhash] para que se generen nuestros archivos con un identificador distinto dependiendo del contenido del mismo.

Ejemplo:
dist/app-4c7a3f0e774715ab1d97.js
vendor-35ae1b622710844bf090.js

Ahora, si compilamos nuestra aplicación:

npm run build

Veremos que se generan dos archivos en la carpeta dist, un app.js que contiene nuestro mensaje de consola con la fecha actual, y un vendor que contiene la lógica necesaria de Webpack, junto con la librería moment.

De manera que, si quisiéramos incluir nuestros archivos en una página html, debemos cargar primero el archivo vendor y luego app.js, en ese orden, para que funcione correctamente.

Automatizando la generación de nuestros vendors:

Si bien, ya estamos cumpliendo con nuestro objetivo de separar los vendors de nuestro código propio, el listado de vendors que incluyen un proyecto puede crecer enormemente, y puede ser tedioso y propenso a errores el tener que mantener dicho listado.

Por lo que vamos a buscar una manera automática (dinámica).

Una forma de lograrlo es haciendo uso de las dependencias definidas en el archivo package.json:


const pkg = require('./package.json');

module.exports = {
  entry: { 
    app: './app.js',
    vendor: Object.keys(pkg.dependencies),
  },
  output: { 
    filename: '[name].js',
    chunkFilename: '[name]-[chunkhash].js', 
  }
}

Pero esto trae desventajas, ya que si olvidamos remover alguna dependencia que no estemos usando, ésta se incluirá en nuestros vendors de todas maneras.
Y si estamos creando una aplicación isomórfica (una aplicación que corre tanto en el lado del servidor como en el lado del cliente), tendríamos que crear alguna lógica que exclusa las librerías que son necesarias para cada caso.

Cómo definir los vendors de manera dinámica:

Una mejor manera de incluir los vendors es definir una función que defina qué librerías incluidas dentro de nuestra aplicación deben agregarse.
El plugin CommonsChunkPlugin permite definir una función en la propiedad minChunks.

Ya que las librerías de terceros los estamos incluyendo usando npm, éstos quedarán en la carpeta node_modules, podemos aprovechar la ruta definida para cada recurso y escribir la función que define qué va y qué no en nuestro archivo de vendor.

/webpack.config.js


...
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name:       'vendor',
            minChunks: (module) => {
                return module.resource && (/node_modules/).test(module.resource)
            }
        })
    ]
}

Esto tiene la ventaja adicional de poder importar funcionalidades de manera optimizada ya que al importar un paquete de esta manera:

import cosa from 'modulo-completo/sub-modulo’

Obtendremos solamente el sub-modulo y no todo el módulo completo.

Asegurando nombres y hashes de manera consistente:

El último paso es asegurarse de que siempre obtengamos el mismo hash y nombre de archivo cada vez que compilamos nuestra aplicación. De manera que podamos guardar en caché nuestros archivos efectivamente.

Deberíamos obtener el mismo hash para nuestro archivo de aplicación si no modificamos el contenido, de igual manera, deberíamos obtener el mismo hash para el archivo vendor si no hemos agregado, actualizado o removido librerías.
La razón por la que esto no sucede es porque Webpack guarda una referencia de cada bundle en los demás que componen la aplicación. Es decir, que cuando se modifica el código propio, la referencia dentro de los vendors del archivo app.js se modifica, por lo que a su vez, el contenido de los vendors cambia y éste obtiene un nuevo hash.

Existen distintas maneras de lograrlo. A continuación les presento una manera de hacerlo:

Para ello vamos a instalar dos plugins de Webpack:

npm install --save-dev chunk-manifest-webpack-plugin@1.1.0 webpack-chunk-hash

Nota: Al probar con versiones distintas a la 1.1.0 del plugin chunk-manifest-webpack-plugin arroja errores.

/webpack.config.js


const path                 = require('path')
const webpack              = require('webpack')
const WebpackChunkHash     = require('webpack-chunk-hash')
const ChunkManifestPlugin  = require('chunk-manifest-webpack-plugin')

module.exports = {
    context: path.resolve(__dirname, 'src'),
    entry: {
        app: './index.js'
    },
    output: {
        path:           path.resolve(__dirname, 'dist'),
        filename:       '[name]-[chunkhash].js',
        chunkFilename:  '[name]-[chunkhash].js'
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader'
            }
        ]
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name:       'vendor',
            minChunks: (module) => {
                return module.resource && (/node_modules/).test(module.resource)
            }
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name:       'manifest',
            minChunks:  Infinity,
            filename:   'manifest.js'
        }),
        new webpack.HashedModuleIdsPlugin(),
        new WebpackChunkHash(),
        new ChunkManifestPlugin({
            filename:           'manifest.json',
            manifestVariable:   'webpackManifest',
            inlineManifest:     true
        })
    ]
}

Primero extraemos en un archivo aparte llamado manifiesto, las referencias de los chunks que componen nuestra aplicación. Así evitamos el problema de que se generen hashes distintos debido a que el contenido de los archivos cambian porque las referencias cambian.

El plugin HashedModuleIdsPlugin ayuda a crear ids o hashses de manera consistente en cada compilación.

El plugin WebpackChunkHash nos permite reemplazar los hashes que Webpack crea por defecto por aquellos que crea el plugin HashedModuleIdsPlugin.

Y por último, el plugin ChunkManifestPlugin, nos permite extraer las referencias de todos los chunks en un archivo json, el cual nos ayudará a saber las partes que componen nuestra aplicación.

Ahora, cuando compilamos nuestra aplicación se generan los siguientes archivos en la carpeta dist:


app-a1680646fb2349c1c569.js
manifest.js
manifest.json
vendor-330ffac2b0f994e84b95.js

De manera que debemos cargar nuestros scripts en el siguiente orden:
1- El manifiesto
2- Los vendors
3- Nuestro código

Ejemplo:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Dynamic Code Splitting</title>
  </head>
  <body>
    <script type="text/javascript" defer src="manifest.js"></script>
    <script type="text/javascript" defer src="vendor-330ffac2b0f994e84b95.js"></script>
    <script type="text/javascript" defer src="app-a1680646fb2349c1c569.js"></script>
  </body>
</html>

Recomendaciones

Se aconseja usar el atributo defer para los tags de los scripts en vez de async, ya que de esta manera el navegador los sigue descargando en paralelo pero los ejecuta en el orden en que aparecen en el documento.

Dado el bajo tamaño del archivo manifiesto (5.79 kB), es recomendado embeber directamente el contenido en un tag script en vez de colocarlo como un recurso a descargar. El tamaño bajará incluso cuando agreguemos el plugin de minificación y habilitemos la compresión gzip del navegador.

Código de ejemplo
https://github.com/rpolidura/luis.osorio/tree/master/webpack/dynamic-code-splitting