Cómo CommonJS agranda tus paquetes

Aprender 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é aumenta el tamaño de tus paquetes de JavaScript 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 del módulo de ECMAScript en toda la aplicación.

¿Qué es CommonJS?

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

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

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

Si invocas 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 bibliotecas 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 crítico como en el navegador. Por este motivo, CommonJS no se diseñó teniendo en cuenta la reducción del tamaño del paquete de producción. Al mismo tiempo, un análisis muestra que el tamaño del paquete de JavaScript sigue siendo el motivo principal por el que las apps de navegador son más lentas.

Los agrupadores y reductores de JavaScript, como webpack y terser, realizan diferentes optimizaciones para reducir el tamaño de tu app. Cuando se analiza tu aplicación en el momento de la compilación, se intenta quitar todo lo posible del código fuente que no estás usando.

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.

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 optimizaciones del modo de producción y usar index.js como punto de entrada. Después de invocar webpack, si exploramos el tamaño de output, 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, forma parte del resultado, que agrega mucho peso adicional a nuestros recursos de producción.

Ahora, cambiemos el formato del módulo a Módulos ECMAScript y vuelva 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 son 40 bytes con el siguiente resultado:

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

Ten en cuenta que el paquete final no contiene ninguna de las funciones de utils.js que no usamos, y no hay ningún registro de lodash. Aún má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é 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 puede no ser tan grande, pero lo más probable es que CommonJS agregue un peso significativo a tu compilación de producción.

En general, los módulos de CommonJS son más difíciles de optimizar porque son mucho más dinámicos que los módulos de ES. Para asegurarte de que el agrupador y el minificador puedan optimizar con éxito 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 los módulos en un solo cierre y permite que el 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 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 que se genera:

/******/ (() => { // 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 se encuentran 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:

  • Quita las funciones sin usar subtract y index_subtract.
  • Quita todos los comentarios y los espacios en blanco redundantes
  • Intercala el cuerpo de la función add en la llamada a console.log.

Con frecuencia, 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 manera 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, ya que se pueden analizar más de forma estática en comparación con CommonJS.

Veamos exactamente el mismo ejemplo, pero esta vez cambia utils.js para que use 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 el contenido es demasiado largo para incorporarlo en esta página, solo compartí una pequeña parte del contenido:

...
(() => {

"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 algún "tiempo de ejecución" webpack: código insertado que es responsable de importar o exportar 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, solicitamos de forma dinámica la función add con __webpack_require__ durante el tiempo de ejecución.

Esto es necesario porque, con CommonJS, podemos obtener el nombre de la 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())] = () => { … };

No hay forma de que el agrupador sepa al momento de la compilación cuál es el nombre del símbolo exportado, ya que esto requiere información que solo está disponible durante 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 del árbol. Observamos exactamente 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.

Movimiento de árboles con CommonJS

Es mucho más difícil analizar los módulos CommonJS, ya que son dinámicos por definición. Por ejemplo, la ubicación de importación en módulos de ES siempre es un literal de cadena, 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 utiliza CommonJS, es posible quitar las exportaciones que no se usan en el tiempo de compilación con un complemento de terceros de webpack. Si bien este complemento agrega compatibilidad con la eliminación de código no utilizado, no abarca todas las diferentes formas en las 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.

Estas son algunas sugerencias prácticas para verificar que estés en la ruta óptima:

  • Cómo usar el elemento node-resolve de Rollup.js y establece la marca modulesOnly para especificar que solo deseas depender 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.