sideEffects en el package.json: la promesa que hace posible borrar un módulo entero
Qué declara exactamente el campo sideEffects, por qué sin él el empaquetador tiene que conservar módulos que no usas, cómo se declara con excepciones, y qué pasa si mientes.
Sin el campo sideEffects, un empaquetador que ve import { boton } from 'mi-libreria' tiene que asumir que evaluar cualquier módulo de esa biblioteca puede hacer algo importante —registrar un elemento personalizado, inyectar estilos, modificar un global— y por tanto conserva la evaluación aunque no use ninguna de sus exportaciones. El campo es la forma de decirle que no, que evaluar los módulos de este paquete no hace nada observable, y con esa promesa puede borrar módulos enteros en lugar de solo funciones sueltas.
- Explicar la diferencia entre eliminar exportaciones y eliminar módulos completos.
- Declarar
sideEffectscorrectamente, con y sin excepciones. - Identificar qué ficheros de un paquete sí tienen efectos secundarios legítimos.
- Diagnosticar el fallo típico que produce declarar el campo cuando no se debe.
Dos niveles de eliminación
Hay que separar dos cosas que la gente mete en el mismo saco.
Eliminar exportaciones no usadas dentro de un módulo que sí se evalúa. Esto lo hace el empaquetador con análisis normal, sin necesidad de sideEffects. Si importas alfa de un módulo que exporta alfa y beta, beta desaparece.
Eliminar la evaluación completa de un módulo. Esto es más agresivo y necesita la promesa. Si tu código importa un módulo y no usa ninguna de sus exportaciones, el empaquetador podría no incluirlo en absoluto. Pero solo si sabe que evaluarlo no hace nada.
La diferencia importa mucho cuando hay barriles. Considera:
// libreria/index.js
export { Boton } from './Boton.js';
export { Modal } from './Modal.js';
export { EditorRico } from './EditorRico.js';
import { Boton } from 'libreria';
Sin sideEffects, el empaquetador incluye index.js y evalúa los tres módulos, porque cada export ... from es una importación con efecto de evaluación. Puede borrar las exportaciones Modal y EditorRico de la salida, pero el cuerpo de esos módulos se evalúa y todo lo que ellos importen se conserva. Con EditorRico arrastrando una biblioteca de 90 KB, no has ahorrado nada.
Con "sideEffects": false, el empaquetador sabe que evaluar Modal.js y EditorRico.js no produce nada observable, y por tanto puede saltarse su evaluación y con ella todas sus dependencias. Ahí es donde están los 90 KB.
Cómo se declara
En el package.json del paquete, en la raíz:
{
"name": "mi-libreria",
"sideEffects": false
}
false significa: ningún módulo de este paquete produce efectos observables al evaluarse. Es la declaración más útil y la que hay que poner en cualquier biblioteca de componentes o de utilidades bien construida.
Cuando algunos ficheros sí tienen efectos, se declaran con una lista de patrones. Los que aparecen en la lista se conservan siempre; el resto se pueden eliminar:
{
"sideEffects": [
"*.css",
"./src/polyfills.js",
"./src/registrar-elementos.js"
]
}
Los ficheros de CSS son el caso obligatorio: un import './boton.css' no tiene exportaciones y su único propósito es el efecto secundario de aplicar estilos. Si no lo declaras, un empaquetador agresivo lo elimina y tus componentes salen sin estilos. Es el fallo más común de este campo y produce un síntoma desconcertante: funciona en desarrollo y en producción se ve sin CSS.
Y esto también aplica a tu propia aplicación, no solo a las bibliotecas. El package.json de tu proyecto puede declarar el campo, y hacerlo permite que tu propio código se recorte mejor:
{
"name": "mi-aplicacion",
"private": true,
"sideEffects": ["*.css", "*.scss", "./src/instrumentacion.ts"]
}
Qué cuenta como efecto secundario
Un módulo tiene efectos secundarios si evaluarlo cambia algo fuera de sí mismo. Los casos legítimos:
Registro de elementos personalizados. customElements.define('mi-boton', MiBoton) en el nivel superior. Si el módulo se elimina, el elemento no existe y el HTML que lo usa no funciona.
Modificación de prototipos o de globales. Cualquier polyfill, cualquier extensión de un prototipo nativo, cualquier asignación a globalThis.
Importación de estilos. El caso del CSS.
Registro en un sistema de plugins. registrarPlugin(...) en el nivel superior de un módulo que se importa solo por su efecto.
Instrumentación. Un módulo que instala manejadores de error globales o inicia la recogida de métricas.
Y los casos que no son efectos secundarios, aunque a mucha gente se lo parezcan:
Crear una constante, aunque sea con una llamada a función. const CACHE = new Map() no toca nada fuera del módulo. Es estado del módulo, no efecto observable.
Definir clases y funciones. Por definición.
Congelar un objeto propio. Object.freeze sobre algo que has creado tú no es observable desde fuera.
El campo es una promesa, y el empaquetador la cree sin verificar. Cuando la promesa es falsa, el resultado no es un error de compilación: es un comportamiento que desaparece.
La forma canónica del fallo. Una biblioteca de componentes con elementos personalizados declara "sideEffects": false porque alguien lo copió de otro proyecto. La aplicación importa un componente del barril y lo usa en la plantilla. El empaquetador ve que registrar-elementos.js no exporta nada que se use, cree la promesa, y lo elimina. En desarrollo funciona, porque el servidor de desarrollo no aplica eliminación de código muerto. En producción, el elemento personalizado nunca se registra y el HTML se queda como un elemento desconocido: no falla, no lanza ningún error, simplemente no hace nada. Cero mensajes en la consola. Y como el elemento desconocido se renderiza como un contenedor en línea vacío, ni siquiera se ve un hueco.
Peor todavía: el fallo es intermitente entre compilaciones. Si otro módulo empieza a importar algo de registrar-elementos.js, la eliminación deja de ocurrir y el fallo se cura solo, hasta que ese otro módulo cambia.
Cuatro reglas para no caer.
Uno: nunca copies el campo de otro proyecto. Es una afirmación sobre tu código.
Dos: cuando tengas dudas, usa la lista en vez de false. El coste de conservar un módulo de más es de kilobytes; el coste de eliminar uno que hacía falta es un fallo silencioso en producción.
Tres: la verificación es una prueba de humo sobre el bundle de producción, no sobre el de desarrollo. Una prueba de extremo a extremo que cargue la página compilada y compruebe que los elementos personalizados están definidos:
// prueba-humo.spec.js — ejecutar contra el build de produccion servido
test('los elementos personalizados estan registrados', async ({ page }) => {
await page.goto('/');
const definidos = await page.evaluate(() =>
['mi-boton', 'mi-modal', 'mi-tabla'].map((n) => [n, !!customElements.get(n)])
);
for (const [nombre, ok] of definidos) expect(ok, nombre).toBe(true);
});Cuatro, y es el que más ahorra a largo plazo: haz que el efecto secundario deje de ser un efecto secundario. En vez de registrar el elemento en el nivel superior de un módulo, exporta una función de registro y llámala desde el punto de entrada de la aplicación. Con eso el módulo pasa a ser puro de verdad, el campo se puede declarar false con honestidad, y el registro es explícito y rastreable:
// registrar.js — puro: exporta, no ejecuta
import { MiBoton } from './MiBoton.js';
import { MiModal } from './MiModal.js';
export function registrarComponentes() {
if (!customElements.get('mi-boton')) customElements.define('mi-boton', MiBoton);
if (!customElements.get('mi-modal')) customElements.define('mi-modal', MiModal);
}// main.js — el unico sitio con efecto, y esta a la vista
import { registrarComponentes } from './registrar.js';
registrarComponentes();La comprobación con customElements.get antes de definir no es paranoia: definir dos veces el mismo nombre lanza una excepción, y con troceado y carga diferida es perfectamente posible que el registro se ejecute dos veces.
La comprobación rápida sobre tus dependencias
Un barrido que revela qué paquetes de tu grafo están bloqueando el recorte:
node -e "
const fs=require('fs'), path=require('path');
const base='node_modules';
for (const nombre of fs.readdirSync(base)) {
if (nombre.startsWith('.')) continue;
const dirs = nombre.startsWith('@')
? fs.readdirSync(path.join(base,nombre)).map(s=>path.join(nombre,s))
: [nombre];
for (const d of dirs) {
const p = path.join(base, d, 'package.json');
if (!fs.existsSync(p)) continue;
const j = JSON.parse(fs.readFileSync(p,'utf8'));
const esm = !!(j.module || (j.exports && JSON.stringify(j.exports).includes('import')));
const se = j.sideEffects;
if (esm && se === undefined) console.log('SIN sideEffects:', d);
}
}
"
Los paquetes que salen en esa lista publican módulos ES —así que el análisis podría funcionar— y no declaran el campo, con lo que el empaquetador tiene que conservar la evaluación de sus módulos. Para los que pesan, merece la pena abrir una incidencia en el repositorio del paquete: es un cambio de una línea con beneficio para todos sus usuarios.
Añade el campo sideEffects al package.json de tu propio proyecto, con la lista de excepciones que corresponda. Compila antes y después y compara el tamaño por punto de entrada. Después ejecuta la prueba de humo sobre el bundle de producción servido: si algo dejó de funcionar, has encontrado un efecto secundario que no habías declarado, y la corrección correcta es convertirlo en una llamada explícita, no añadirlo a la lista.