Cómo CommonJS agranda tus paquetes

Descubre cómo los módulos de CommonJS afectan el tree-shaking de tu aplicación

En esta publicación, veremos qué es CommonJS y por qué hace que tus paquetes de JavaScript sean más grandes de lo necesario.

Resumen: Para garantizar que el empaquetador pueda optimizar tu aplicación correctamente, evita depender de los módulos de CommonJS y usa la sintaxis del módulo de ECMAScript en toda la aplicación.

¿Qué es CommonJS?

CommonJS es un estándar de 2009 que estableció convenciones para los módulos de JavaScript. En un principio, se diseñó para usarse fuera del navegador web, principalmente para aplicaciones del servidor.

Con CommonJS, puedes definir módulos, exportar funciones desde ellos y, luego, importarlos en otros módulos. Por ejemplo, el siguiente fragmento define un módulo que exporta cinco funciones: add, subtract, multiply, divide y max:

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Más adelante, otro módulo puede importar y usar algunas o todas estas funciones:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

Invocar index.js con node mostrará el número 3 en la consola.

Debido a la falta de un sistema de módulos estandarizado en el navegador a principios de la década de 2010, CommonJS también se convirtió en un formato de módulo popular para las bibliotecas del cliente de JavaScript.

¿Cómo afecta CommonJS al tamaño final del paquete?

El tamaño de tu aplicación de JavaScript del servidor no es tan importante como en el navegador, por lo que CommonJS no se diseñó teniendo en cuenta la reducción del tamaño del paquete de producción. Al mismo tiempo, el análisis muestra que el tamaño del paquete de JavaScript sigue siendo el motivo principal por el que las apps para navegadores son más lentas.

Los empaquetadores y reductores de JavaScript, como webpack y terser, realizan diferentes optimizaciones para reducir el tamaño de tu app. Cuando analizan tu aplicación en el tiempo de compilación, intentan quitar la mayor cantidad posible del código fuente que no usas.

Por ejemplo, en el fragmento anterior, tu paquete final solo debe incluir la función add, ya que este es el único símbolo de utils.js que importas en index.js.

Compilemos la app con la siguiente configuración de webpack:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

Aquí especificamos que queremos usar las optimizaciones del modo de producción y usar index.js como punto de entrada. Después de invocar webpack, si exploramos el tamaño del resultado, veremos algo como lo siguiente:

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

Ten en cuenta que el paquete es de 625 KB. Si observamos el resultado, encontraremos todas las funciones de utils.js, además de muchos módulos de lodash. Aunque no usamos lodash en index.js, es parte del resultado, lo que agrega mucho peso adicional a nuestros recursos de producción.

Ahora, cambiemos el formato del módulo a módulos de ECMAScript y volvamos a intentarlo. Esta vez, utils.js se vería de la siguiente manera:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

Y index.js importaría desde utils.js con la sintaxis del módulo de ECMAScript:

import { add } from './utils.js';

console.log(add(1, 2));

Con la misma configuración de webpack, podemos compilar nuestra aplicación y abrir el archivo de salida. Ahora son 40 bytes con el siguiente resultado:

(()=>{"use strict";console.log(1+2)})();

Observa que el paquete final no contiene ninguna de las funciones de utils.js que no usamos y no hay rastro de lodash. Además, terser (el minificador de JavaScript que usa webpack) intercala la función add en console.log.

Una pregunta razonable que podrías hacer es ¿por qué usar CommonJS hace que el paquete de salida sea casi 16,000 veces más grande? Por supuesto, este es un ejemplo de juguete. En realidad, la diferencia de tamaño podría no ser tan grande, pero es probable que CommonJS agregue un peso significativo a tu compilación de producción.

Los módulos CommonJS son más difíciles de optimizar en general porque son mucho más dinámicos que los módulos ES. Para asegurarte de que el empaquetador y el minificador puedan optimizar tu aplicación de forma correcta, evita depender de los módulos de CommonJS y usa la sintaxis del módulo de ECMAScript en toda la aplicación.

Ten en cuenta que, incluso si usas módulos de ECMAScript en index.js, si el módulo que consumes es un módulo CommonJS, el tamaño del paquete de tu app se verá afectado.

¿Por qué CommonJS hace que tu app sea más grande?

Para responder esta pregunta, analizaremos el comportamiento de ModuleConcatenationPlugin en webpack y, luego, analizaremos la analizabilidad estática. Este complemento concatena el alcance de todos tus módulos en un cierre y permite que tu código tenga un tiempo de ejecución más rápido en el navegador. Veamos un ejemplo:

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

Arriba, tenemos un módulo de ECMAScript, que importamos en index.js. También definimos una función subtract. Podemos compilar el proyecto con la misma configuración de webpack que antes, pero esta vez, inhabilitaremos la reducción:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

Veamos el resultado producido:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

En el resultado anterior, todas las funciones están dentro del mismo espacio de nombres. Para evitar colisiones, webpack cambió el nombre de la función subtract en index.js a index_subtract.

Si un minificador procesa el código fuente anterior, hará lo siguiente:

  • Se quitaron las funciones subtract y index_subtract que no se usaban.
  • Quita todos los comentarios y los espacios en blanco redundantes.
  • Une el cuerpo de la función add en la llamada a console.log.

A menudo, los desarrolladores se refieren a esta eliminación de importaciones no utilizadas como "tree-shaking". El árbol de reducción solo fue posible porque webpack pudo comprender de forma estática (en el tiempo de compilación) qué símbolos importamos desde utils.js y qué símbolos exporta.

Este comportamiento está habilitado de forma predeterminada para los módulos de ES porque se pueden analizar de forma más estática en comparación con CommonJS.

Veamos el mismo ejemplo, pero esta vez cambia utils.js para usar CommonJS en lugar de módulos de ES:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Esta pequeña actualización cambiará significativamente el resultado. Como es demasiado largo para incorporarlo en esta página, solo compartí una pequeña parte:

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

Observa que el paquete final contiene un "entorno de ejecución" webpack: código insertado que se encarga de importar o exportar funciones de los módulos empaquetados. Esta vez, en lugar de colocar todos los símbolos de utils.js y index.js en el mismo espacio de nombres, requerimos de forma dinámica, en el tiempo de ejecución, la función add con __webpack_require__.

Esto es necesario porque, con CommonJS, podemos obtener el nombre de exportación de una expresión arbitraria. Por ejemplo, el siguiente código es una construcción absolutamente válida:

module.exports[localStorage.getItem(Math.random())] = () => {  };

El empaquetador no puede saber en el momento de la compilación cuál es el nombre del símbolo exportado, ya que esto requiere información que solo está disponible en el tiempo de ejecución, en el contexto del navegador del usuario.

De esta manera, el minificador no puede comprender qué usa exactamente index.js de sus dependencias, por lo que no puede eliminarlo. También observaremos el mismo comportamiento para los módulos de terceros. Si importamos un módulo CommonJS desde node_modules, tu cadena de herramientas de compilación no podrá optimizarlo correctamente.

Eliminación de código no utilizado con CommonJS

Es mucho más difícil analizar los módulos de CommonJS, ya que son dinámicos por definición. Por ejemplo, la ubicación de importación en los módulos de ES siempre es una cadena literal, en comparación con CommonJS, donde es una expresión.

En algunos casos, si la biblioteca que usas sigue convenciones específicas sobre cómo usa CommonJS, es posible quitar las exportaciones que no se usan en el tiempo de compilación con un plugin webpack de terceros. Si bien este complemento agrega compatibilidad con el recorte de árboles, no cubre todas las diferentes formas en que tus dependencias podrían usar CommonJS. Esto significa que no obtienes las mismas garantías que con los módulos de ES. Además, agrega un costo adicional como parte del proceso de compilación además del comportamiento predeterminado de webpack.

Conclusión

Para garantizar que el empaquetador pueda optimizar tu aplicación de forma correcta, evita depender de los módulos de CommonJS y usa la sintaxis del módulo de ECMAScript en toda la aplicación.

A continuación, se incluyen algunas sugerencias prácticas para verificar que estás en la ruta óptima:

  • Usa el complemento node-resolve de Rollup.js y establece la marca modulesOnly para especificar que deseas depender solo de los módulos de ECMAScript.
  • Usa el paquete is-esm para verificar que un paquete de npm use módulos de ECMAScript.
  • Si usas Angular, de forma predeterminada, recibirás una advertencia si dependes de módulos que no se pueden sacudir del árbol.