Uso de paquetes npm directamente desde el navegador
Usa paquetes npm en HTML simple con import maps y URLs de CDN. Elige ESM o CommonJS, fija versiones y evita el paso de compilación.
Puedes usar un paquete npm en una página HTML sencilla sin bundler, sin node_modules y sin archivo de configuración, declarando un import map que apunte un especificador simple (bare specifier) hacia una URL de CDN que sirva ese paquete como módulo ES.
Una página, una librería, una interacción: rara vez justifica montar un proyecto Vite, con su servidor de desarrollo, su directorio de salida de build y toda su historia de despliegue. Lo que suele fallar no es la sintaxis del import map: los paquetes npm se distribuyen en tres formatos de módulo distintos, y solo dos de ellos funcionan en un navegador. Este artículo explica cómo identificar qué formato tienes entre manos, las dos formas de cargarlo desde un CDN, y por qué una URL sin versión fijada es un error de corrección y no una cuestión de estilo.
Puntos clave
- Un import map es un bloque JSON dentro de una etiqueta
<script type="importmap">que le indica al navegador a qué URL se resuelve un especificador simple comocanvas-confetti, es decir, el mismo trabajo que hace un bundler en tiempo de build, trasladado a la página. - Un import map no puede rescatar un paquete que solo se distribuye en CommonJS, porque un map cambia cómo se resuelve un especificador, no el formato en el que está escrito el archivo.
- MDN clasifica los import maps como Baseline Widely available, con soporte en todos los navegadores desde marzo de 2023.
- Fija una versión exacta en cada URL de CDN del map, o el código que ejecuta tu página podrá cambiar sin un despliegue y sin un commit.
- Omitir el paso de build significa que no hay tree shaking, así que envías todo lo que contiene el paquete en lugar de solo las partes que usas.
¿Cuándo conviene omitir el paso de build?
Omite el paso de build cuando el coste de mantenerlo sobreviva a aquello que construye. Eso cubre una demo estilo CodePen, un único widget interactivo insertado en una plantilla de WordPress o en una vista de Rails, un panel interno que usan dos personas, y cualquier prototipo cuya vida útil se mida en días. El criterio no es el tamaño, sino la propiedad: si nadie va a actualizar el toolchain en seis meses, el toolchain es un pasivo. Todo lo que esperas que crezca, que reciba tráfico real o que acabe en manos de un equipo sigue perteneciendo a un bundler.
Tres tipos de archivo, de los cuales dos funcionan en un navegador
Un paquete npm se distribuye en uno de tres formatos de módulo, y solo dos de ellos funcionan en un navegador, así que averigua qué build trae el paquete antes de escribir ningún import map. Un archivo clásico o UMD funciona con un <script src> sencillo y asigna una variable global. Un módulo ES necesita type="module" y sentencias import. Un build de CommonJS, escrito con require() y module.exports, no se ejecuta en un navegador en absoluto.
La forma más rápida de averiguarlo es instalar el paquete y leerlo:
npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json
Fíjate en dos cosas de esa salida: las extensiones de los archivos del paquete y los campos de punto de entrada. La documentación de paquetes de Node define main, exports y type; module es una convención del ecosistema que leen los bundlers y los CDN, no un campo especificado por Node. Algunos paquetes incluyen además un campo jsdelivr o unpkg que nombra un build listo para el navegador. Por ejemplo, canvas-confetti@1.9.4 declara "main": "src/confetti.js", "module": "dist/confetti.module.mjs" y "jsdelivr": "dist/confetti.browser.js" en su package.json, lo que te indica que existen tanto un build para navegador como un build de módulo ES.
| Formato | Cómo reconocerlo | Qué necesita el navegador | Sin paso de build |
|---|---|---|---|
| Clásico / UMD | .umd.js, un dist/*.browser.js, o código fuente que asigna a window | Nada especial | <script src> y después usar la variable global |
| Módulo ES | .mjs, import/export en el código fuente, "type": "module" | type="module" | Import map más un script de tipo module |
| CommonJS | .cjs, require(), module.exports, "type": "commonjs" | Conversión previa | Un CDN que transpile a ESM, o un paso de build |
Esa última fila es donde la mayoría de los intentos fracasan en silencio. Un import map no puede rescatar un paquete que solo se distribuye en CommonJS, porque un map cambia cómo se resuelve un especificador, no el formato en el que está escrito el archivo.
El enfoque simple: una etiqueta script desde un CDN
Si el paquete trae un build clásico o UMD, una sola etiqueta script es toda la integración. El nombre de la variable global lo elige el autor del paquete, no tú, así que consulta el README: el README de canvas-confetti indica que su build de CDN coloca una función confetti en window.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
Si el paquete trae ESM o CommonJS, es el CDN quien hace la conversión. Una petición al endpoint /+esm de jsDelivr devuelve un módulo ES listo para el navegador, y jsDelivr lo describe como bastante más que un cambio de sintaxis: determina el punto de entrada correcto a partir de los propios campos del paquete, convierte CommonJS cuando hace falta, incorpora las dependencias en la respuesta y depura y minifica lo que devuelve. esm.sh hace el trabajo equivalente bajo la gramática de URL https://esm.sh/PKG[@SEMVER][/PATH]. Cualquiera de los dos te da una URL que puedes poner directamente en una sentencia import.
El mejor enfoque: una etiqueta script de tipo importmap
Un import map es un bloque JSON dentro de una etiqueta <script type="importmap"> que asocia especificadores simples con URLs, de modo que tu código de módulo se lee exactamente igual que dentro de un bundler.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
La diferencia que te compra esto es una línea. Sin el map, cada archivo que necesite la librería repite la URL del CDN y la versión:
import confetti from 'https://esm.sh/canvas-confetti@1.9.4';
Con el map, la versión vive en un único lugar y la sentencia import es portable, sin cambios, a un proyecto con bundler.
En la práctica importan cuatro reglas. Primera, el orden decide si el map funciona en absoluto: el navegador tiene que leerlo antes de encontrarse con cualquier script de tipo module que importe a través de él, así que el bloque <script type="importmap"> va por encima de ese código. Segunda, el estándar HTML permite que un documento contenga más de un map y especifica cómo se fusionan, pero el soporte de los motores no es uniforme, así que escribe un map por documento. Tercera, los valores relativos deben empezar por /, ./ o ../. Cuarta, una barra final en ambos lados de una asociación mapea un directorio de paquete completo en lugar de un único punto de entrada:
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
"canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>
MDN clasifica los import maps como Baseline Widely available, presentes en los navegadores desde marzo de 2023, así que un polyfill ya no forma parte de una configuración normal. Si de todos modos quieres una comprobación en tiempo de ejecución, HTMLScriptElement.supports() te la da, usada como HTMLScriptElement.supports?.("importmap").
Un detalle traicionero que no viene acompañado de ningún mensaje de error: los módulos ES se descargan bajo las reglas de CORS, así que abrir el archivo HTML desde el disco falla, aunque ese mismo archivo funcione en el momento en que un servidor local te lo sirve.
Fija la versión, siempre
Fija una versión exacta en cada URL de CDN del map. Una URL sin versión fijada o basada en un rango significa que el código que ejecuta tu página puede cambiar sin un despliegue, sin un commit y sin nada en tu repositorio que explique la diferencia. El comportamiento desplegado de la página pasa a ser una función del reloj del CDN en lugar de tu historial de git, lo que convierte un informe de bug rutinario en un ejercicio de arqueología: el HTML no ha cambiado, los logs del servidor no han cambiado, y el JavaScript es distinto.
Esta es la única regla que no tiene ninguna ventaja al romperse. canvas-confetti@1.9.4 es un hecho sobre el que puedes razonar; canvas-confetti@latest es una promesa que mantiene otra persona.
¿A qué renuncias?
Cargar paquetes desde un CDN le entrega a un origen de terceros la capacidad de ejecutar script arbitrario en el contexto de tu página. Puedes acotar eso con una CSP y con subresource integrity: MDN señala que el objeto JSON del import map admite una clave integrity junto a imports y scopes, que asocia URLs de módulos con hashes SRI como sha384-…. Si prefieres controlar por completo la ruta de entrega, servir tus propios assets es otra configuración, tratada en los roles de los CDN en el rendimiento del frontend y en una comparativa de plataformas CDN.
Hay otros tres costes que vienen de serie. No hay tree shaking, así que envías todo lo que contiene el paquete en lugar de solo las partes que usas, lo cual es un trato razonable para una demo y malo para una aplicación que esperas que crezca. Un grafo de dependencias profundo resuelto en tiempo de ejecución significa que el navegador descubre cada módulo solo después de descargar a su padre, y por eso intervienen los CDN: esm.sh empaqueta por defecto los submódulos de un paquete en la respuesta, reteniendo únicamente los que comparten los puntos de entrada que declara su campo exports, y ?bundle=false desactiva ese comportamiento. Y el modo de fallo es silencioso: el documento se parsea, el layout queda completo, y un módulo nunca llega porque un proxy, una extensión o una regla de CSP bloqueó el origen, que es precisamente el tipo de bug que el session replay saca a la luz más rápido que un informe de errores, ya que nunca se lanzó nada.
Para cualquier cosa sustancial en producción, usa un bundler. Esta técnica es para las cosas que no lo justifican.
Empieza leyendo el paquete antes de escribir una línea de HTML: lista los archivos, lee main, module, exports y type, y decide a partir de ahí si necesitas una etiqueta script, un import map o, después de todo, un paso de build.
Preguntas frecuentes
¿Puedo mantener el import map en un archivo JSON separado en lugar de inline en el HTML?
No. La especificación prohíbe que un elemento script de tipo importmap lleve un atributo src, así como async, nomodule, defer, crossorigin, integrity y referrerpolicy, por lo que el JSON tiene que ir dentro del documento. Si el map se genera, renderízalo en la página desde el servidor en lugar de enlazarlo, y mantenlo por encima del primer script de tipo module.
¿Cómo cargo dos versiones distintas del mismo paquete en una sola página?
Usa la clave scopes. Un scope asocia un segundo mapa de especificadores a una ruta de URL, de modo que los scripts cargados desde esa ruta pueden resolver un paquete a una versión fijada mientras el resto de la página lo resuelve a otra. Cuando dos scopes coinciden, se comprueba primero la ruta más larga, y el mapa imports actúa como respaldo. La alternativa más sencilla es dar a cada versión su propio especificador simple.
¿Se aplican los import maps a los web workers o al atributo src de una etiqueta script?
No. Un map solo reescribe especificadores en sentencias import y llamadas import() dentro del propio documento. La URL del atributo src de una etiqueta script nunca pasa por él, y tampoco nada cargado dentro de un worker o un worklet. Un import dinámico dentro de un módulo del documento sí se resuelve a través del map, pero el script de entrada de un worker y sus propios imports necesitan URLs completas.
¿Qué ocurre si un especificador simple no está en el import map?
La resolución lanza un TypeError antes de que el módulo se ejecute, y los dos motores lo redactan de forma distinta. Chrome informa de que no pudo resolver el especificador del módulo, nombra el especificador y añade que las referencias relativas deben empezar por /, ./ o ../ (cada uno de esos tres aparece entre comillas en el mensaje real). Firefox informa: The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”. Nada en el código de tu aplicación lanza un error, así que la página se renderiza con normalidad y solo la funcionalidad que dependía de ese módulo queda muerta.