Cómo CommonJS agranda tus paquetes

Descubre cómo los módulos de CommonJS afectan la eliminación de código no utilizado 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 asegurarte de que el agrupador pueda optimizar correctamente tu aplicación, evita depender de módulos de CommonJS y usa la sintaxis de módulos de ECMAScript en toda tu aplicación.

¿Qué es CommonJS?

CommonJS es un estándar de 2009 que estableció convenciones para los módulos de JavaScript. Inicialmente, estaba destinado para su uso fuera del navegador web, principalmente para aplicaciones del lado del servidor.

Con CommonJS, puedes definir módulos, exportar funciones desde ellos e 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 de estas funciones o todas:

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

Si se invoca index.js con node, se 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 de cliente de JavaScript.

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

El tamaño de tu aplicación de JavaScript del lado del servidor no es tan crítico como en el navegador. Por ello, CommonJS no se diseñó para reducir el tamaño del paquete de producción en mente. Al mismo tiempo, un análisis muestra que el tamaño del paquete de JavaScript sigue siendo el motivo principal por el que las aplicaciones de navegador son más lentas.

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

Por ejemplo, en el fragmento anterior, el 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.

Compila 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 optimizaciones del modo de producción y usar index.js como punto de entrada. Después de invocar a webpack, si exploramos el tamaño del resultado, veremos algo como esto:

$ 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 y muchos módulos de lodash. Aunque no usamos lodash en index.js, es parte de la salida, 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á 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 es 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 que no hay seguimiento de lodash. Además, terser (el minificador de JavaScript que usa webpack) integró la función add en console.log.

Una pregunta razonable que podrías hacerte es ¿por qué el uso de 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 lo más probable es que CommonJS le agregue un peso significativo a tu compilación de producción.

Los módulos CommonJS son más difíciles de optimizar en los casos generales porque son mucho más dinámicos que los módulos de ES. Para asegurarte de que el agrupador y el minificador puedan optimizar correctamente tu aplicación, evita depender de 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 ECMAScript en index.js, si el módulo que consumes es un módulo CommonJS, se verá afectado el tamaño del paquete de tu app.

¿Por qué CommonJS agranda tu app?

Para responder a esta pregunta, observaremos el comportamiento de ModuleConcatenationPlugin en webpack y, luego, analizaremos el análisis estático. 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 se indicó antes, pero esta vez inhabilitaremos la minimizació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 generado:

/******/ (() => { // 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 reductor procesa el código fuente de arriba, hará lo siguiente:

  • Quita las funciones sin usar subtract y index_subtract.
  • Quita todos los comentarios y los espacios en blanco redundantes
  • Cómo intercalar 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 sin usar como eliminación de código no utilizado. La eliminación de código no utilizado solo fue posible porque webpack pudo comprender de forma estática (en el tiempo de compilación) qué símbolos estamos importando 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 cambiemos utils.js para usar CommonJS en lugar de módulos 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 a esta página, compartí solo 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));

})();

Ten en cuenta que el paquete final contiene algunos "entorno de ejecución" de webpack: código insertado que es responsable de importar/exportar la funcionalidad 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, durante el tiempo de ejecución, la función add usando __webpack_require__.

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

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

No hay forma de que el agrupador sepa durante el tiempo de 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 hacer un sacudido de los árboles. Observamos exactamente el mismo comportamiento para módulos de terceros. Si importamos un módulo de CommonJS desde node_modules, tu cadena de herramientas de compilación no podrá optimizarlo correctamente.

Elimina árboles con CommonJS

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

En algunos casos, si la biblioteca que usas sigue convenciones específicas sobre el uso de 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 la eliminación de código no utilizado, no abarca todas las formas diferentes en que tus dependencias podrían usar CommonJS. Esto significa que no obtienes las mismas garantías que con los módulos ES. Además, agrega un costo adicional como parte de tu proceso de compilación, además del comportamiento predeterminado de webpack.

Conclusión

Para asegurarte de que el agrupador pueda optimizar correctamente tu aplicación, evita depender de módulos de CommonJS y usa la sintaxis del módulo de ECMAScript en toda la aplicación.

A continuación, te presentamos 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 estás usando Angular, de forma predeterminada recibirás una advertencia si dependes de módulos que no admiten la eliminación de código no utilizado.