TinselCity

¿Por qué usar Webpack?

Pregunta: estoy teniendo dificultades para entender por qué querría yo usar Webpack, y para qué sirve en realidad. […] Parece que ya no pudiera simplemente soltar un <script> en mi página o algo.

Cargando JavaScript

En lugar de centrarme en las peculiaridades de Webpack, creo que es más interesante y útil extender la pregunta a todos los empaquetadores de JavaScript. Habitualmente llamados bundlers, existe una amplia variedad, como Webpack pero también, Rollup, Browserify, Parcel, JSPM, y otros, que ofrecen una funcionalidad similar. También se pueden conseguir resultados razonablemente parecidos con un script de diseño propio.

En cualquier caso, dejemos claro que por supuesto que puedes simplemente soltar un <script> en tu página. Es una solución totalmente válida y todavía usada en ciertas situaciones.

Dicho esto, intentemos entender cuál es “el problema” y recorrer todo el camino hasta lo que podría ser una solución genérica (Browserify, Webpack, etc). Tengo que indicar, eso sí, que algunas de estas herramientas también pueden procesar otro tipo de recursos (CSS, imágenes, tipografías…) pero sólo nos vamos a centrar en JavaScript.

Orígenes

Así que tienes una página con un único <script>. Podemos imaginarnos que el script entero pueden ser, digamos, 500 líneas de JS. La vida es bella, más o menos. El script es manejable, no demasiado grande. Pero según pasa el tiempo, el proyecto crece. Quizá hemos ido teniendo que añadir más funcionalidades a la página (o aplicación). Quizá la empresa ha contratado más desarrolladores para que trabajen contigo en el proyecto.

Podrías continuar tal cual, con tu configuración actual. Un único fichero y tú (y quizá más) añadiéndole funcionalidad. Antes o después, temrinarás encontrando dos problemas principales.

  • Primero, tu fichero único crecerá. Crecerá con cada nueva funcionalidad. Esto es malo porque los ficheros grandes son más difíciles de mantener, es más incómodo encontrar determinadas partes en ellos y es más complicado tener a varias personas trabajando sobre un único fichero.
  • Segundo, probablemente querrás usar alguna librería externa. Esto no es un problema en sí, pero en un momento veremos que puede añadirse a la situación general.

Partiendo el código

Con este problema en la cabeza, decidimos obviamente partir el código en varios ficheros más pequeños. Esto no es solo por el problema del fichero grande, claro, también es una buena práctica aplicar la separación de intereses y tener partes independientes unas de otras. Hace que el código sea más limpio y organizado. Podemos querer, por ejemplo, separar las funciones que manejan el DOM de otras que hacen peticiones XHR o de otras que procesan datos, etc.

Digamos que habíamos llegado a unas 10kLOCs y que rápidamente partimos el fichero en, por ejemplo, 20 más pequeños, de unas 500LOC cada uno. No es excelente, pero es aceptablemente mejor.

Pero, ¿cómo manejamos estos ficheros? La aproximación más simple es, bueno, simplemente soltar 20 etiquetas <script> en nuestra página. Bueno, quizá 20 más otras 5 o 6 de librerías externas que hemos añadido por el camino. (Imagina jQuery y 5 ó 6 plug-ins, o lo que quieras.)

Hay una cosa que debemos tener en cuenta, eso sí: el orden. Tenemos que tener cierto cuidado para insertar estas etiquetas en un orden particular elegido con cuidado porque, claro, tenemos que asegurar que una cierta función esté disponible antes de que otras partes intenten usarla.

Ahora la vida es mejor en algunos aspectos. Tenemos fichero de código fuente más pequeños, lo cual ayuda mucho. Pero no todo es bonito. Hemos añadido estas otras cosas de las que preocuparnos. Y el problema que hemos añadido no es solo el tema del orden de esas 35 etiquetas <script>. También ocurre que cuando los desarrolladores tienen que añadir nuevas funciones tienen que dedicar algo de esfuerzo a pensar dónde poner esas funciones. ¿En qué fichero deben ir para que se carguen cuando deben? ¿Será mejor añadir un nuevo fichero y una nueva etiqueta <script>?

Además, ahora que hemos aprendido eso de separar intereses, cada vez producimos más y más ficheros pequeños y encima los querremos organizar en carpetas y esas cosas.

Ya tenemos unos 50, 80… unos 100 ficheros de JavaScript. Podemos ver cómo el código es mucho mejor ahora, pero también vemos estos nuevos problemas.

Nuevos problemas

Aparecen nuevos problemas. Tenemos la cantidad de etiquetas <script> y su orden que debemos mantener… y ahora algo más: Estos ficheros definen un montón de nombres. Nombres de funciones, nombres de variables. Y cuando estás editando un fichero, no tienes ahí a la vista los nombres ya usados en otros ficheros. Aparecen colisiones de nombres. El desarrollo se vuelve un tedioso ejercicio de, o bien tener que recordar nombres ya usados, o bien mantener algún tipo de convención de nombres horrible o… ¿Quizá podríamos buscar alguna solución para esto?

Así que vamos a buscar (en nuestro propio conocimiento, o en un libro, tutorial, preguntando en reddit, lo que sea) y descubrimos ciertos patrones de módulos (seguramente el “Revealing Module Pattern”). No voy a describirlo aquí en detalle. Si lo necesitas hay montones de sitios donde leer sobre ello en la red, pero baste decir que es una estructura con esta pinta…

    let algo = (function() {
        // aquí código que consideramos "privado"
        // ...

        // y luego...
        return {
            publicOne: ...,
            publicTwo: ...
        };
    })();

…que básicamente te proporciona, a través de una clausura, con cierta encapsulación. La cosa devuelta en el return y asignada a algo tiene algunos métodos y/o propiedades que son visibles y tienen acceso a las cosas que son locales definidas dentro de la expresión de función, y a lo que nadie más tiene acceso. De esta forma, hasta cierto punto, es una estructura que nos permite escribir bloques encapsulados con una cierta visibilidad “privada”. ¿Por qué esto es interesante para nuestro problema?

Soluciona las colisiones de nombres.

Lo que hacemos entonces es, en cada uno de esos 100, ahora 120, ficheros que tenemos, creamos esta estructura, englobando todo el contenido del fichero. Al final, solo devolvemos las cosas que queremos que sean visibles.

Esto es una gran mejora, porque nos quitamos del medio casi todos los problemas de colisiones de nombres. Por otro lado seguimos teniendo 120, ahora 180 ficheros y etiquetas <script>. Y el tema ese del orden, ufff. La vida no es tan bella por ese lado.

El primer script

Recapitulemos un instante:

  1. Hemos conseguido resolver el problema de tener un único enorme fichero. Era una problema, porque realmente era enorme y porque necesitábamos tener varias personas trabajando a la vez sobre él y eso era doloroso.
  2. Pero ahora tenemos estos otros problemas:
    1. Tenemos 220 etiquetas <script> en la página.
    2. Hay que mantenerlas en un cierto orden.
    3. Ah, y alguna gente por ahí se queja de que se tarda mucho en cargar 230 ficheros por separado. Antes o después tendremos que atajar este asunto. Por ahora simplemente lo tendremos en mente.

Queriendo progresar, intentamos resolver estos nuevos problemas.

Y hay una solución relativamente simple. No resolverá todo, pero es simple y algo ayuda: Podemos hacer un script o alguna herramienta similar que simplemente concatene todos los ficheros de JavaScript uno detrás de otro en un determinado orden. Así, el primer problema se soluciona por completo. Nuestro código fuente está en 273 ficheros separados pero el script incluido en la página vuelve a ser solo uno. Así que sólo tendremos una etiqueta <script>.

Esto es un gran progreso, seguro. Pero el segundo problema sigue estando ahí. Para poder concatenar, necesitamos decir de algún modo al script cuál es el rden correcto.

Podemos pasar por varia soluciones más o menos ingenuas, que pueden funcionar en mayor o menor medida.

Podríamos, por ejemplo, nombrar nuestros ficheros siguiendo un cierto patrón, como 00100-somefile.js, 001300-anotherfile.js… y luego hacemos que el script los concatene siguiendo el orden de esos números. Más o menos funciona. No es bonito de mantener, pero más o menos funciona. Al principio usamos números consecutivos pero tras un día que tuvimos que renombrar 80 ficheros, entonces empezamos a dejar espacios huecos entre los números.

O podríamos mantener una lista con al orden correcto, con alguna herramienta, o algunas otras ideas.

Cualquier solución de este estilo sigue siendo un kludge y no resuelve realmente el problema. Lo intenta hacer un poco menos doloroso, pero poco más.

Así que, en lugar de esto, volvemos a consultar nuestro conocimiento y recursos. Quizá haya un patrón algo más sofisticado que el RMP o alguna otra cosa que podamos añadirle.

Primera aproximación a los módulos

Lo pensamos un tiempo y se nos ocurre que sería muy interesante si pudiéramos añadir algún modo para que un fichero concreto pueda decir que otros ficheros necesita para poder ejecutarse. Sus… dependencias, si quieres lamarlo así.

No tengo intención de escribir cómo sería esa solución porque llevaría mucho espacio aquí y no quiero desviarnos del tema original, pero, si quieres, puedes mirar RequireJS para ver la pinta que una aproximación cercana a esta podría tener. (RequireJS hereda en cierta medida del sistema de módulos de Dojo, pero no quiero hacer daño a nadie recomendando ir a ver la documentación de Dojo.)

//my/shirt.js now has some dependencies, a cart and inventory
//module in the same directory as shirt.js
define(["./cart", "./inventory"], function(cart, inventory) {
        //return an object to define the "my/shirt" module.
        return {
            color: "blue",
            size: "large",
            addToCart: function() {
                inventory.decrement(this);
                cart.add(this);
            }
        }
    }
);

Por otro lado, mientras estábamos haciendo todo esto, una gente por ahí publica NodeJS y se hace popular.

Esto es relevante porque una de las cosas que incluye es precisamente un mecanismo para hacer lo que queremos: definir “módulos” que pueden tener partes privadas/locales, puede exportar otras partes públicas, y puede requerir otros módulos.

El mecanismo de NodeJS (en realidad basado en una especificación de CommonJS) con su sintaxis concreta, se vuelve muy popular también. Hayq ue notar que, más tarde, el estándar de ES decidirá una sintaxis y mecanismo diferentes pero esto no importa realmente demasiado; la pieza importante es que hay alguna sintaxis particular que se hace popular, y por tanto, que sería una buena idea seguir esa sintaxis.

Pero, claro, la sintaxis de NodeJS funciona bien en NodeJS. Y aunque ECMAScript finalmente estandarice otra sintaxis, el soporte real para gestionar la carga de módulos no está disponible de manera general en los navegadores. Además, estos sistemas están encaminados a cargar múltiples ficheros individuales y nosotros realmente no querríamos servirlos por separado.

Sea como sea, la sintaxis deja fuera la mayor parte de la ceremonia sobre clausuras y simplemente nos permite escribir los módulos de una manera como…

    // Una de estas dos para "pedir" una dependencia:
    let a = require('a.js'); // NodeJS modules
    import a from 'a.js';    // ES modules

    // ...tu código aquí...

    // Una de estas (o variaciones similares) para hacer visibles algunas cosas fuera de tu módulo:
    module.exports = something; // NodeJS
    export something;           // ES

Podemos ver que esto, en sí, es bueno, y decidimos ir por este camino.

El segundo script

Aún necesitamos empaquetar todos los ficheros en uno, y además tenemos que hacer que ese fichero funcione en un navegador (donde no disponemos de soporte para el sistema de módulos que sí tenemos en NodeJS). Por esto, necesitamos revisar nuestro script de empaquetado que simplemente concatenaba todo y convertirlo en algo un poco más sofisticado.

Nota que podríamos optar por diversas aproximaciones.

Una aproximación que podemos probar es, por un lado, hacer que:

  1. Antes que ninguno de nuestros ficheros, el script insertará una serie de funciones genéricas de utilidad. Esto es principalmente porque, como decía, en el navegador no tenemos soporte para esos require(…) y module.exports = …, así que tendremos que proporcionarlo nosotros.
  2. Los contenidos de cada fichero se envolverán con un poco de andamiaje. Piensa en este envoltorio como, aproximadamente, el código que hemos eliminado al ir del trozo de código de RequireJS al del código de NodeJS. No exactamente tal cual, pero la idea general es la misma. Es decir, lo que hacemos es meter el código del módulo dentro de una expresión de función (como en el Revealing Module Pattern) pero en lugar de ejecutarlo directamente, lo que hará será pasar esa expresión a una de esas funciones que añadimos al inicio del fichero.

Todo esto tiene un efecto neto: Cada módulo lo escribimos de la forma cómoda que hemos aprendido a apreciar. Pero después, cuando llega el momento de ejecutarlo, nosotros gestionamos cómo (y cuándo) se ejecuta. Esto porque cuando nuestro código llame a require(“a.js”), nuestras funciones añadidas serán capaces de proporcionar el objeto del módulo o, si aún no ha sido cargado, puede retener la ejecución de nuestro módulo hasta que a.js esté finalmente disponible.

Generalmente esto se hace manteniendo algún tipo de registro o unos identificadores que permiten al sistema referenciar los módulos correctamente, como si estuviera referenciando ficheros o algo similar.

Nuestro propio empaquetador

Volvamos a recapitular.

  1. Tenemos un código fuente manejable, con el código separado funcionalmente en pequeños módulos. Esto es muy bueno.
  2. Tenemos un proceso o herramienta que:
    1. Pone todos los pequeños ficheros en uno solo más grande.
    2. Añade algunas funciones genéricas que:
      1. Se ocupan de proporcionar a cada módulo con las dependencias que solicite
      2. Resuelve el problema del orden.

¿Cómo se soluciona el problema del orden? ¿Me he perdido algo? Simple: Como he dicho, la ejecución de los módulos ahora está gestionada y podemos, si es necesario retrasar la ejecución de un módulo si sus dependencias aún no están disponibles. Así que no necesitamos ya preocuparnos por el problema del orden. Cargamos primero todos los módulos y después ejecutamos lo que queramos.

Nota que no se trata solo de esto. En realidad el sistema de dependencias puede funcionar con nuestro código tanto si está encapsulado y empaquetado como si está en ficheros separados y la cargar se produce bajo demanda cuando son requeridos o importados. Mientras el sistema proporcione los mecanismos y entienda la misma sintaxis, ganaremos esta habilidad para nuestro código sin que éste tenga que notarlo de ningún modo.

Ahora, esta herramienta es algo que podemos hacer nosotros mismos, tal como hemos ido imaginando. Pero sería “mejor” si toda la gente que estuviera haciendo y usando herramientas similares las hiciera que funcionaran igual y usaran la misma sintaxis. O incluso si usaran la misma herramienta. Así podemos compartir código y tratar a las librerías externas escritas así de la misma forma que tratamos a nuestro propio código.

Por eso, en lugar de construirnos nuestra propia herramienta, usamos una existente. Estas herramientas son Browserify, Webpack, Parcel, etc.

Algunas de estas, como decía al principio, aprovechan el hecho de que ya estamos haciendo todas estas transformaciones y empaquetados en un proceso, para ofrecernos otras tareas también. Tareas como minificar el código (comprimirlo para que ocupe menos y cargue más rápido). O a lo mejor otras son suficientemente sofisticadas para poder evitar incluir código que saben que no se usa (tree-shaking). O pueden procesar también otro tipo de recursos como CSS y/o imágenes. Una vez que hemos accedido a usar una herramienta o proceso como parte de nuestro flujo de trabajo, bueno, ¿por qué no intentar aprovecharlo más?

Parte 2 - Detalles adicionales

Lo que sigue es una extensión de lo anterior que no es necesaria. No proporciona más información sobre por qué usamos empaquetadores, el problema que resuelven, o cómo llegamos hasta ellos.

Código generado

A continuación solo un poco más de detalle sobre cómo se hace en la realidad. Me centraré más en Browserify, más que nada porque es el más sencillo, pero todos los empaquetadores trabajan de un modo similar y producen resultados similares. No te preocupes demasiado por el código en sí, ya que no es un ejemplo completo.

Imaginemos que uno de nuestros ficheros (algo llamado linkloader.js) tiene esta pinta:

    const xhr = require('../lib/xhr.js');
    const dom = require('../lib/domUtils.js');

    function loader(container) {
        const output = dom.printTo(container);

        xhr.get(href, function(content) {
            var { content, js } = dom.parse(content);
            // ...
        });
    }

    module.exports = loader;

He quitado la mayor parte del código pero los detalles interesantes (importar y exportar) están ahí. Así que ejecutamos Browserify sobre nuestro código y nos devuelve un paquete. No voy a mostrar todo el resultado de eso porque es demasiado grande y ruidoso. Pero este fichero en concreto se transforma en algo como esto:

    {
        1: ...,
        2:[
                function(require,module,exports){
                    const xhr = require('../lib/xhr.js');
                    const dom = require('../lib/domUtils.js');

                    function loader(container) {
                        const output = dom.printTo(container);

                        xhr.get(href, function(content) {
                            var { content, js } = dom.parse(content);
                        // ...
                        });
                    }
                    module.exports = loader;
                },
                {"../lib/domUtils.js":4,"../lib/fnbasics.js":5,"../lib/xhr.js":6}
        ],
        3: ...
    }

Es decir, Browserify lo mete todo en un objeto. Este será lo que le pase a esa función que mencionaba más arriba que se encargará de ejecutar cada módulo/expresión. Como se puede ver, la transformación es poco más que envolver el código original y extraer los nombres de las dependencias de las que cada módulo hace require i.e. lee cada require que hacemos y saca el nombre a un objeto auxiliar donde tenerlos todos juntos. Esto es por simple comodidad.

Es interesante notar, como vemos en la expresión de función, que nuestro código se va a ejecutar teniendo acceso a 3 cosas:

  • una función require
  • referencias a module y a exports.

Esto es todo lo que necesitamos para que el código funcione y es interesante que a nuestro código no le importa mucho cómo funcionan internamente estas funciones y referencias en detalle. Solo que hagan lo que dicen. Esto es lo que nos permite lo que decía antes: que sin que el código tenga que cambiar de ningún modo, el sistema de módulos puede funcionar como aquí, empaquetado en un único fichero, o en algún otro modo (e.g. cargando las dependencias bajo demanda por XHR o leyéndolos del sistema de ficheros).

Si quieres más detalle y ver la pinta que tienen esas funciones o el bloque completo al inicio del empaquetado, puedes echar un ojo al paquete browser-pack. Pero una idea general podría ser hacer algo como esto.

    // Tengo el objeto con todas las funciones así que:
    forEach(key, module) -> {
        funct = module[0]; dependencies = module[1];
        registry[key] = execute(funct, getDeps(dependencies, registry) );
    }

Esto, por supuesto, es una pseudo-código muy ingenuo y poco aproximado. Una solución real deberá pensar en la disponibilidad de las dependencias antes de ser usadas. También esto en realidad no funciona así en absoluto en lo que respecta al registro de módulos porque tu código no hace return. En lugar de eso añades cosas al module.exports, pero eso es ya un detalle de implementación sin interés.

Ahora, he usado Browserify porque es más simple que Webpack. La salida generada por Webpack es similar en espíritu. Webpack construye un array en lugar de un objeto y envuelve los módulos en algo con esta pinta:

    /* 1 */
    /***/
    (function(module, exports, __webpack_require__) {
                    const xhr = __webpack_require__(0);
                    const dom = __webpack_require__(3);
                    // ...
    }),

(Los comentarios están ahí solo por motivos de depuración manual, hasta donde yo sé.)

Como se puede ver, la diferencia principal es que Webpack aplica alguna transformación adicional al código mientras está generandoe l empaquetado. La transformación principal es cambiar require por su propio __webpack_require__ para evitar conflictos de nombres. Este es un cambio bastante superficial pero además, mientras lo hace, elimina las referencias a nombres de los ficheros originales. En lugar de eso, los cambia directamente por un índice numérico. En cualquier caso, el resultado es similar: Todos los beneficios explicados más arriba, se mantienen.

Además, como ya decía antes, Webpack hace más que esto. Esto es todo en relación a los módulos, pero Webpack también incluye otras tareas que podrías hacer con otro software. Como comprimir (minificar) la salida, o gestionar el CSS en coordinación con el JavaScript, o ejecutar un transpilador… O una relativamente común: Como decía, hay dos sintaxis la de CommonJS/NodeJS y la de ESM (el estándar): require('bla.js') vs import form “bla.js”. En principio, mientras Browserify solo soporta CommonJS, Webpack lo que hace es transformar ambas formas en tiempo de empaquetado a la forma común usando __webpack_require__.

(Nota que esto no es estrictamente correcto. Webpack 1 tampoco soportaba import, pero Webpack 2 (y más) sí. Y también es posible combinar Browserify con otras herramientas -Babel- para que hagan la transformación y luego Browserify el empaquetado.)

¿Futuro? ¿Soporte para Módulos en los Navegadores?

Queda solo una única cuestión que podríamos plantearnos. Podría ser algo así como: “Bueno, ahora que ya hay una forma estándar de cargar módulos, no podríamos simplemente usar eso y olvidarnos de todo esto del empaquetado y dejar que los navegadores carguen lo que necesiten?”

La respuesta no es tan inmediata. Digamos que…

  • Mientras que hay un estándar (casi completamente, algunos detalles aún generan discusión), no ha habido implementaciones disponibles en algún navegador hasta hace muy poco. Las últimas versiones de algunos navegadores están empezando a proporcionar (cierto) soporte para módulos ESM justo ahora. (Ver el aviso al comienzo de esta página dela MDN.)
  • En el futuro, puede que haya una forma o la forma, pero por ahora necesitando soportar a los navegadores actualmente en uso, la solución parece inevitable que pase por proporcionar un sistema de empaquetado/módulos que nos dé lo que esos navegadores no tienen.
  • Hay algunas otras consideraciones que afectan a esto. En particular el rendimiento y soporte de HTTP2 puede o puede que no ayude a volver a servir múltiples ficheros independientes. Es ligeramente complicado de determinar aún, pero puede significar que haya diferentes escenarios y que en algunos el empaquetado peuda funcionar mejor.

Así que la respuesta a esto último es un clásico depende. O si lo prefieres, podría ser: “Por ahora, empaquetar es buena idea en muchos casos. Con el tiempo, ya veremos”.