Importar assets como módulos: url, raw e inline
En Vite cada archivo —una imagen, un JSON, un CSS, un shader— es un nodo del grafo de módulos, y al importarlo recibes un valor que depende de su tipo: una URL con hash, un objeto parseado o un efecto de lado. Los sufijos de consulta url, raw e inline son el mando fino con el que anulas esa heurística y decides qué cara del mismo nodo quieres ver.
La idea que abre este nivel es engañosamente simple: en Vite, un asset no es un archivo suelto que copias a mano, sino un módulo más del grafo de dependencias. Cuando escribes import de una imagen, un JSON o un CSS, el bundler lo trata con la misma maquinaria que aplica a tu código —lo resuelve, lo procesa, lo deduplica y le da una URL coherente—. Lo que cambia de un tipo a otro es el valor que recibes al importarlo, y los sufijos ?url, ?raw e ?inline son el mando con el que anulas la decisión por defecto cuando no es la que quieres.
- Entender que un asset importado es un módulo cuyo valor por defecto depende de su tipo.
- Distinguir qué devuelve cada familia: imagen y fuente una URL, JSON un objeto, CSS un efecto.
- Dominar los sufijos
?url,?rawe?inliney saber cuándo forzar cada uno. - Cargar muchos assets a la vez con
import.meta.globy tipar todo convite/client.
Un asset es un módulo más
Cuando importas una imagen, el valor por defecto que recibes es una cadena: la URL ya resuelta del recurso. En desarrollo es una ruta que sirve el propio dev server; en el build es una ruta con hash bajo tu assetsDir. Nunca escribes esa ruta a mano —importas el archivo fuente y Vite te devuelve la dirección del artefacto procesado.
import logoUrl from "./logo.png";
// dev: logoUrl === "/src/assets/logo.png"
// build: logoUrl === "/assets/logo.2f3a9c1b.png"
const img = document.createElement("img");
img.src = logoUrl;
El punto clave es la inversión de responsabilidad: tú declaras una dependencia hacia un archivo, y el bundler te garantiza que la cadena que recibes apunta al artefacto final, esté donde esté, se llame como se llame y viva incrustado o como archivo aparte. Toda la contabilidad de rutas y nombres deja de ser tuya.
Esa cadena, además, respeta la base de despliegue. Si publicas la app bajo un subcamino, la URL que recibes ya lo incluye, porque Vite la resuelve contra import.meta.env.BASE_URL en lugar de asumir la raíz del dominio. Es otra tarea que el grafo te quita de encima: no solo el nombre con hash, también el prefijo correcto según dónde viva la aplicación. Un import de asset es, por eso, portable entre despliegues de un modo que una ruta escrita a mano nunca lo es.
Lo que devuelve cada tipo
No todos los assets se proyectan al mismo tipo de valor. La familia de recursos binarios —imágenes, fuentes, audio, vídeo— devuelve su URL. Un JSON devuelve el objeto ya parseado, y además expone named exports por cada clave de primer nivel, lo que permite que en el build se eliminen por tree-shaking las claves que no usas. Un CSS importado por su efecto no devuelve nada útil: inyecta o recolecta el estilo. Y un CSS Module devuelve el mapa de nombres de clase locales.
// Imagen: la URL ya procesada
import heroUrl from "./hero.avif";
// JSON: objeto parseado mas named exports tree-shakeables
import config, { version } from "./config.json";
// CSS con efecto de lado: inyecta el estilo, sin valor util
import "./reset.css";
// CSS Module: mapa de clases locales con hash
import styles from "./card.module.css";
element.className = styles.card;
| Importas | Valor por defecto | Nota |
|---|---|---|
| png, svg, avif, woff2 | la URL con hash | como cadena |
| json | el objeto parseado | named exports por clave |
| css de efecto | ninguno útil | inyecta o extrae el estilo |
| module.css | mapa de clases | nombres locales con hash |
Los sufijos: url, raw e inline
La heurística por defecto es buena, pero no siempre es la que quieres. Los sufijos de consulta la anulan por importación. ?url fuerza que recibas la URL resuelta como cadena, incluso para tipos que Vite procesaría de otro modo: es la vía para obtener la dirección de un script, un worklet o una hoja de estilos que quieres cargar tú mismo. ?raw devuelve el contenido crudo del archivo como texto —un shader GLSL, una plantilla, un fragmento de código que renderizas literal—. ?inline fuerza la incrustación como data URI base64 ignorando el umbral de tamaño, y ?no-inline fuerza lo contrario: siempre un archivo aparte.
// La URL de un asset, sin importar la heuristica por defecto
import workletUrl from "./audio-worklet.js?url";
// El contenido crudo del archivo como cadena
import shaderSource from "./fragment.glsl?raw";
// Forzar data URI aunque supere el umbral
import iconInline from "./check.svg?inline";
// Forzar archivo aparte aunque sea diminuto
import pixelFile from "./1px.png?no-inline";
Para una imagen el valor por defecto ya es la URL, así que ?url sobra. Pero para un .css, un .json, un .svg tratado como componente o un .ts, el valor por defecto puede ser un efecto de lado, un objeto o una función. ?url cortocircuita todo eso y te entrega solo la cadena con la ruta del archivo emitido. Úsalo cuando quieras la dirección del recurso, no su contenido: por ejemplo, para pasarle a una API del navegador la URL de un archivo que ella cargará por su cuenta.
flowchart LR N[nodo del grafo: logo.png] --> V1[por defecto: la url con hash] N --> V2[sufijo raw: el texto del archivo] N --> V3[sufijo inline: un data uri base64] style N fill:#cba6f7,color:#11111b style V1 fill:#a6e3a1,color:#11111b
Más allá de los tres: worker, wasm y la consulta como canal
El mismo mecanismo alimenta sufijos especializados. ?worker importa un archivo como Web Worker listo para instanciar; ?sharedworker hace lo propio con un Shared Worker; ?init sobre un .wasm te da una función que inicializa un módulo WebAssembly. Y los sufijos participan del grafo como cualquier import: el worker se empaqueta, se hashea y se sirve con su propia URL.
// El archivo como Web Worker instanciable
import CrearWorker from "./tarea.js?worker";
const w = new CrearWorker();
// Un modulo WebAssembly con su inicializador
import init from "./suma.wasm?init";
const instancia = await init();
La lección de fondo es que la consulta tras la interrogación es un canal abierto: plugins y el propio Vite añaden proyecciones nuevas del mismo nodo —un worker, un módulo WASM, un componente— sin inventar una sintaxis de importación distinta cada vez. Aprendes una gramática, import más sufijo, y la reutilizas para todo lo que el grafo sepa emitir.
Muchos assets de golpe
Cuando necesitas decenas de archivos —todos los iconos de una carpeta, todos los posts— importarlos uno a uno es inviable. import.meta.glob toma un patrón glob y devuelve un mapa de ruta a función importadora perezosa. Con eager: true se resuelven de forma estática, y con query e import moldeas qué recibes de cada uno.
// Mapa perezoso: ruta -> funcion que importa bajo demanda
const posts = import.meta.glob("./posts/*.md");
// Eager mas solo la URL de cada asset
const icons = import.meta.glob("./icons/*.svg", {
eager: true,
query: "?url",
import: "default",
});
// icons["./icons/home.svg"] === "/assets/home.a1b2c3.svg"
Esto se resuelve en tiempo de build: el glob se expande a imports concretos, de modo que cada archivo participa del hashing, el tree-shaking y el code splitting como cualquier otra dependencia. No es una lectura de disco en runtime, sino azúcar sobre el grafo.
Para que TypeScript entienda estas importaciones —que un .png es una cadena, que ?raw es texto, que un .module.css es un objeto— basta con incluir los tipos ambientales de Vite. Añade la directiva de referencia en un archivo de tipos o el paquete a tu tsconfig.
/// <reference types="vite/client" />
?url
La dirección del artefacto emitido como cadena, saltándote el proceso por defecto del tipo.
?raw
El contenido crudo del archivo como texto, sin transformar: shaders, plantillas, ejemplos literales.
?inline
Los bytes incrustados como data URI base64, ignorando el umbral de tamaño del build.
El movimiento profundo de este nivel es que Vite —y Rollup o Rolldown por debajo— se niega a tratar el código y los assets como dos mundos distintos. Un PNG, una fuente, un JSON: todos se convierten en nodos del mismo grafo de dependencias que tus módulos. Esa única decisión es la que vuelve coherente todo lo que viene después. Porque la imagen es un nodo, el bundler puede resumirla por su contenido con un hash, deduplicar dos importaciones del mismo archivo en un único artefacto emitido, podar por tree-shaking las referencias que ninguna rama alcanza, y reescribir en una sola pasada consistente cada URL que apunta a ella. El sufijo de consulta no es entonces más que un pequeño lenguaje de dominio por importación para elegir cómo se proyecta ese nodo a un valor: ¿quieres su dirección con ?url, su texto con ?raw o sus bytes incrustados con ?inline? No estás configurando un loader; estás eligiendo qué cara del mismo nodo del grafo quieres ver. Por eso “copiar el archivo y ya” es casi siempre el instinto equivocado en un proyecto Vite: en el instante en que esquivas el grafo pierdes el hashing, la deduplicación y la reescritura de URLs, y heredas justo la contabilidad manual que el grafo existía para abolir. Aprende a pensar cada asset como un módulo, y el resto del nivel —hashing, inlining, extracción de CSS, la carpeta public/— deja de ser una pila de banderas y se convierte en un puñado de consecuencias de esa sola idea.
- Importa una imagen y registra su valor en dev y tras
vite build; observa cómo la ruta cambia y aparece el hash. - Importa un JSON con un named export y confirma en el build que las claves que no usas desaparecen por tree-shaking.
- Importa el mismo archivo tres veces —por defecto, con
?rawy con?url— y compara los tres valores que recibes. - Usa
import.meta.globconeageryquery: "?url"para construir un mapa de todos los SVG de una carpeta. - Quita la referencia a
vite/clienty observa a TypeScript quejarse de la importación de la imagen; vuelve a añadirla.