wandres.dev
ROLLUP · el modelo de plugins

Output formats: esm, cjs, iife y umd

Un mismo grafo de módulos puede emitirse en varias formas, y cada una habla el idioma de un consumidor distinto. El eje `output.format` de Rollup: `esm` para bundlers y ESM nativo, `cjs` para Node y toolchains antiguos, `iife` para un script suelto en el navegador, `umd` para funcionar en cualquier entorno. Cuándo emitir cada uno, por qué `output.name` y `output.globals` son obligatorios en los formatos de navegador, y cómo publicar un paquete dual sin caer en sus trampas.

⏱ 17 min

El tree shaking y el scope hoisting deciden qué código entra en el bundle; los output formats deciden en qué idioma sale. Rollup construye una vez el grafo y luego lo emite en la forma que pidas: un módulo de ES para bundlers, un módulo de CommonJS para Node, una función autoejecutable para un <script> suelto, o un envoltorio universal que se adapta a lo que encuentre. Elegir bien el formato no es un detalle de configuración: es responder con precisión a la pregunta de quién va a consumir tu código y bajo qué sistema de módulos.

🎯 Al terminar esta lección sabrás
  • Dominar el eje output.format y sus cuatro valores centrales.
  • Distinguir los formatos de módulo —esm, cjs— de los de navegador —iife, umd—.
  • Saber por qué output.name y output.globals son obligatorios en iife y umd.
  • Decidir qué formato emitir según el consumidor, y cómo publicar un paquete dual.

El eje output.format

Toda la elección se concentra en un campo: output.format. Sus valores canónicos son es —con los alias esm y module—, cjs —alias commonjs—, iife, umd, y los heredados amd para RequireJS y system para SystemJS. El grafo es el mismo en todos; lo que cambia es el envoltorio con que Rollup lo entrega y el sistema de carga que ese envoltorio asume del otro lado.

Como output admite un array, un solo build puede emitir varios formatos a la vez. Es el patrón habitual de una librería que quiere servir a consumidores dispares sin reconstruir el grafo tres veces:

// rollup.config.js
export default {
  input: 'src/index.js',
  external: ['react'],
  output: [
    { file: 'dist/index.mjs', format: 'es' },
    { file: 'dist/index.cjs', format: 'cjs' },
    {
      file: 'dist/index.umd.js',
      format: 'umd',
      name: 'MiLib',
      globals: { react: 'React' },
    },
  ],
};

esm y cjs: para sistemas de módulos

El formato esm es el destino por defecto de una librería moderna. Emite import y export tal cual, de modo que el consumidor —otro bundler, o Node con ESM nativo— pueda a su vez analizarlo y hacer tree shaking de lo que no use. Es el único formato que preserva la estructura estática del grafo aguas abajo: al reexportar ESM, no cierras ninguna puerta.

El formato cjs traduce ese mismo grafo a require y module.exports. Su razón de ser es la compatibilidad: Node en su modo clásico, herramientas de test antiguas, y cualquier consumidor que aún no cargue ESM. El coste es que CommonJS no es analizable estáticamente, así que quien importe tu cjs recibe el paquete entero y pierde la capacidad de podarlo. Emites cjs por alcance, no por calidad de salida.

La decisión de qué formato emitir se reduce a quién consume el código y bajo qué sistema de módulos:

  • esm: para otro bundler o Node con ESM nativo. El destino por defecto de una librería moderna.
  • cjs: para Node clásico o toolchains con require. Se añade por compatibilidad, no por calidad.
  • iife: para un <script> suelto que expone un global. El drop-in de navegador sin dependencias.
  • umd: para una librería que debe correr en cualquier entorno sin saber cómo la van a cargar.
ℹ️
Los alias existen porque el nombre del formato tuvo historia

es, esm y module designan exactamente el mismo formato; Rollup los acepta todos por compatibilidad con configuraciones antiguas. Lo mismo con cjs y commonjs. No hay diferencia técnica entre escribir uno u otro: elige el que tu equipo lea mejor y sé coherente. Lo que sí importa es no confundir el formato de salida con la extensión del archivo o con el campo type del package.json: son tres decisiones separadas que deben coincidir para que Node interprete bien lo que emites.

iife y umd: para el navegador directo

Los dos formatos restantes existen para un mundo sin bundler: cargar un archivo con <script> y usarlo de inmediato. El formato iife envuelve todo en una función que se ejecuta al vuelo —una immediately-invoked function expression— y expone un único global con el nombre que le des en output.name. Es el drop-in clásico: un <script src> y tu librería aparece como window.MiLib, sin ningún sistema de módulos de por medio.

El formato umd —universal module definition— va un paso más allá: al arrancar, detecta el entorno y se adapta. Si hay un cargador AMD, se registra como AMD; si hay CommonJS, se exporta como module.exports; si no hay ninguno, cae en el global como iife. Es el formato universal de las librerías que debían funcionar en cualquier página sin saber de antemano cómo se iban a cargar. Por eso durante años los CDN servían umd por defecto.

Ambos formatos comparten dos exigencias que esm y cjs no tienen. La primera es output.name: como exponen un global, necesitan saber cómo llamarlo. La segunda es output.globals: si tu código depende de algo externo —React, por ejemplo—, en el navegador ese externo también vive como global, y Rollup necesita el mapa que traduce el especificador react al nombre React de window.

// output para un umd que asume React ya cargado en la pagina
{
  format: 'umd',
  name: 'MiLib',
  globals: { react: 'React' },
}

Quedan dos formatos heredados que rara vez elegirás hoy, pero conviene reconocer. amd es el formato de RequireJS, pensado para la carga asíncrona en navegadores anteriores a los módulos nativos; system es el de SystemJS, un cargador dinámico que soportaba ESM y su semántica de live bindings antes de que los navegadores lo hicieran de serie. Ambos pertenecen a una era previa a <script type="module">: emitirlos solo tiene sentido si mantienes un entorno que aún los exige.

flowchart TD
Q[para quien empaquetas] --> L[libreria publicada en npm]
Q --> C[script suelto en el navegador]
Q --> A[aplicacion con bundler]
L --> ES[formato es y opcional cjs]
C --> II[formato iife o umd con name]
A --> CH[formato es troceado en chunks]
📦

esm

Para bundlers y ESM nativo de Node. Preserva la estructura estática: el consumidor puede seguir haciendo tree shaking.

🟢

cjs

Para Node clásico y toolchains con require. Amplía el alcance a costa de perder el análisis estático aguas abajo.

🌐

iife

Para un <script> directo. Autoejecutable, expone un único global vía output.name. Sin sistema de módulos.

🌎

umd

Universal: se adapta a AMD, CommonJS o global en runtime. El formato de las librerías que deben correr en todas partes.

⚠️
El paquete dual y su trampa

Publicar a la vez esm y cjs —un paquete dual— resuelve el alcance, pero abre un riesgo conocido como dual package hazard: si una parte del árbol de dependencias carga tu versión esm y otra la cjs, coexisten dos copias del mismo módulo con estado separado, y comprobaciones como instanceof fallan porque las clases no son idénticas. Se mitiga cableando bien el campo exports del package.json con condiciones import y require que apunten al mismo comportamiento, y manteniendo el estado con carga lateral fuera del módulo dual. Emitir dos formatos es fácil; garantizar que nadie cargue los dos a la vez es el trabajo de verdad.

El campo exports es quien conecta cada condición de carga con el archivo del formato correcto, y es la pieza que hace usable un paquete dual:

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
El formato no es una opción de menú: es un contrato con el consumidor

La tentación del principiante es tratar output.format como una casilla que rellenar y copiar del ejemplo de turno. La verdad es que cada formato codifica una hipótesis sobre quién está del otro lado y qué sabe hacer. esm dice: te empaqueta otra herramienta, así que te doy estructura para que la aproveches. cjs dice: te carga un Node que no habla ESM, así que renuncio a la pureza por llegar a ti. iife dice: no hay ninguna herramienta, solo un navegador y un <script>, así que me convierto en un global y me ejecuto solo. umd dice: no sé quién eres, así que pregunto en runtime y me adapto. Elegir el formato es, literalmente, elegir a tu audiencia. Y por eso una librería seria no elige uno: emite varios en una sola pasada, porque su público es heterogéneo y su trabajo es no dejar a nadie fuera. Cuando entiendes esto dejas de copiar configuraciones y empiezas a diseñar la superficie de distribución de tu código: qué formatos, con qué exports, para qué consumidores, con qué garantías. Esa es la diferencia entre publicar un paquete y publicar un paquete que la gente pueda usar sin sorpresas. El grafo lo construye el bundler; el contrato con el mundo lo firmas tú en el bloque output.

⚔️ Emite el mismo grafo en cuatro idiomas
  1. Configura un output como array que emita es, cjs, iife y umd a partir del mismo input, y observa las cuatro salidas.
  2. Abre el iife y localiza dónde se expone el global que definiste con output.name.
  3. Marca una dependencia como external, dale su entrada en output.globals, y comprueba cómo el umd la referencia en vez de incluirla.
  4. Cablea un package.json con exports que apunte al esm bajo la condición import y al cjs bajo require, y razona qué evita eso.
  5. Explica en dos frases por qué el esm deja hacer tree shaking al consumidor y el cjs no.