Módulos virtuales y el prefijo nulo
Cómo generar código en tiempo de build sin un archivo real: el patrón de los módulos virtuales, el par resolveId más load que los sostiene, la convención del prefijo de byte nulo que marca un id como no-archivo, por qué existe esa marca, los casos de uso reales —inyectar datos, config como módulo, manifest, agregación de globs— y cómo hacerlos reactivos al HMR en Vite 8.
No todo lo que importas tiene por qué existir como archivo. Un módulo virtual es código que un plugin genera al vuelo y sirve como si fuera un módulo normal, pero que nunca toca el disco. Detrás de import.meta.glob, de virtual:pwa-register, de los iconos que importas por nombre o de la configuración que llega como módulo, hay siempre el mismo patrón mínimo: un resolveId que reconoce un id inventado y un load que fabrica su contenido. La pieza que hace que todo encaje limpiamente es una convención discreta pero fundamental: el prefijo de byte nulo.
- Entender qué es un módulo virtual y por qué a veces es mejor que un archivo real.
- Implementar el patrón
resolveIdmásloadque lo sostiene. - Explicar por qué la convención del prefijo nulo
\0marca un id como no-archivo. - Reconocer los casos de uso reales y hacer que un módulo virtual sea reactivo al HMR.
Un módulo sin archivo
La idea es tan simple como potente: en vez de escribir un archivo, generar su contenido en el momento del build. Si necesitas exponer datos que solo se conocen al compilar —el hash del commit, la fecha de build, la lista de rutas de tu app, un catálogo de iconos— tienes dos caminos. Uno es escribir un archivo generado en disco con un script previo, con todos los problemas de sincronización que eso arrastra: queda obsoleto, se versiona por error, se olvida de regenerar. El otro es un módulo virtual: el import existe en tu código, pero su contenido lo produce un plugin cada vez, siempre fresco, sin ensuciar el árbol de archivos.
import infoBuild from 'virtual:build-info'
console.log(infoBuild.commit, infoBuild.fecha)
Ese import es real y funciona, pero no hay ningún virtual:build-info en el disco. Cuando el bundler intente resolverlo, un plugin lo interceptará, le dará una identidad y le fabricará el código. El usuario del plugin escribe un import idiomático; el plugin hace la magia por detrás. Esa separación —una interfaz de import limpia sobre generación dinámica— es lo que hace tan agradables de consumir a los buenos plugins de Vite.
El patrón: resolveId más load
Un módulo virtual se implementa siempre con el mismo par de hooks que ya conoces. resolveId reconoce el especificador público y devuelve el id interno; load reconoce ese id interno y devuelve el código generado. Entre ambos media la convención del prefijo nulo, que veremos en un momento.
export function pluginBuildInfo(): Plugin {
const idPublico = 'virtual:build-info'
const idResuelto = '\0' + idPublico
return {
name: 'build-info',
resolveId(source) {
if (source === idPublico) return idResuelto
return null
},
load(id) {
if (id === idResuelto) {
const datos = {
commit: obtenerCommit(),
fecha: new Date().toISOString(),
}
return `export default ${JSON.stringify(datos)}`
}
return null
},
}
}
Fíjate en la estructura. El id público, virtual:build-info, es el que escribe el usuario en su import. El id resuelto, el mismo pero con un byte nulo delante, es el que circula por dentro del bundler. resolveId traduce del uno al otro y load responde solo al segundo. Los dos hooks devuelven null para todo lo demás, respetando su naturaleza de primero que gana y cediendo el turno cuando el especificador no les incumbe.
La convención completa recomienda prefijar el id público con el nombre de tu plugin: virtual:mi-plugin/datos en vez de virtual:datos. En un proyecto con varios plugins que generan módulos virtuales, un nombre genérico como virtual:config es una colisión esperando ocurrir. El espacio de nombres bajo tu plugin hace que dos plugins puedan convivir sin pisarse los ids, igual que un paquete de npm con scope evita chocar con otro del mismo nombre.
Por qué el byte nulo
El prefijo \0 no es decorativo: es una señal que Rollup y todo su ecosistema entienden. Un id que empieza por byte nulo significa “esto no es un archivo del sistema”. Esa marca tiene varias consecuencias concretas, y cada una resuelve un problema real.
Nadie lo lee del disco
Sin la marca, otro plugin o el resolutor por defecto podría intentar leer virtual:datos como una ruta y fallar. El byte nulo declara que no hay archivo que buscar.
Otros plugins lo respetan
Los plugins bien educados ignoran los ids con prefijo nulo que no son suyos. Evita que un plugin de, digamos, PostCSS intente procesar tu módulo generado.
Sourcemaps limpios
Al no ser un archivo, el bundler no intenta resolver su ruta en los sourcemaps, y lo muestra con un nombre especial en lugar de una ruta rota.
Nombres de salida legibles
Rollup transforma el byte nulo en un prefijo visible al nombrar chunks, así que en el output reconoces de un vistazo qué vino de un módulo virtual.
Sin esta convención, los módulos virtuales serían frágiles. Imagina que devuelves virtual:datos tal cual desde resolveId, sin la marca: el sistema de sourcemaps intentaría localizar un archivo con ese nombre, otros plugins de load podrían reclamarlo, y en algún punto alguien haría un readFile que reventaría. El byte nulo es un carácter que no puede aparecer en una ruta de archivo real de ningún sistema operativo, lo que lo convierte en el marcador perfecto: inequívoco y universal. Es una de esas convenciones humildes que sostienen silenciosamente todo un ecosistema.
flowchart LR I[import de virtual dos puntos build-info] --> R[resolveId reconoce el id publico] R --> V[id interno con prefijo nulo] V --> L[load fabrica el codigo] L --> C[export default con los datos] C --> G[entra en el grafo como un modulo mas]
Casos de uso y reactividad
El patrón aparece en todas partes una vez que sabes verlo. import.meta.glob de Vite agrega bajo el capó un conjunto de imports en un módulo generado. Los plugins de iconos que te dejan escribir import Flecha from '~icons/mdi/arrow' fabrican cada componente como módulo virtual. La configuración de un framework, las rutas basadas en el sistema de archivos, un manifest de assets, un registro de plugins de tu propia app: todos son módulos virtuales que convierten información dispersa en un import único y tipado.
import.meta.glob
La cara pública de un módulo virtual: Vite agrega un patrón de archivos en un objeto de imports, perezosos o ansiosos, sin que escribas ninguno a mano.
Iconos por nombre
~icons/set/nombre no existe en disco: el plugin fabrica cada componente al vuelo la primera vez que lo importas.
Rutas del sistema de archivos
Los frameworks sobre Vite convierten tu carpeta de páginas en un módulo virtual de rutas, regenerado cuando añades o borras un archivo.
Config como módulo
Un virtual:config expone valores resueltos en build —flags, entorno, versión— como un import tipado en vez de un objeto global suelto.
Ahora bien, un módulo virtual que depende de archivos externos necesita un cuidado extra para el desarrollo: reactividad. Si tu load lee un YAML de datos para generar el módulo, el dev server no sabe que ese YAML influye en nada, porque no es un módulo del grafo. La solución combina dos herramientas ya vistas. En load, declaras la dependencia con this.addWatchFile(rutaDelYaml). Y cuando el archivo cambie, invalidas el módulo virtual para forzar su regeneración, buscándolo en el grafo por su id resuelto y llamando a server.moduleGraph.invalidateModule.
handleHotUpdate({ file, server }) {
if (file.endsWith('.datos.yaml')) {
const mod = server.moduleGraph.getModuleById('\0virtual:build-info')
if (mod) server.moduleGraph.invalidateModule(mod)
server.ws.send({ type: 'full-reload' })
return []
}
}
El listón de calidad de un módulo virtual es que el usuario no note que lo es. Eso implica tres cosas: un id público con nombre de espacio propio, tipos publicados para que el editor autocomplete el import —normalmente en un archivo de declaraciones que el usuario referencia—, y reactividad al HMR para que editar la fuente de datos actualice el módulo sin reiniciar. Si te saltas la reactividad, tu módulo virtual será correcto en producción pero incómodo en desarrollo, y la incomodidad en desarrollo es lo que hace que la gente abandone un plugin.
Los módulos virtuales disuelven una frontera que casi todo el mundo da por sentada: la de que un import tiene que corresponder a un archivo. Esa suposición es tan vieja como los propios lenguajes de programación, y el patrón resolveId más load la rompe con una elegancia notable. De pronto, cualquier cosa que sepas computar en tiempo de build —el estado de tu repositorio, el resultado de escanear una carpeta, la fusión de mil archivos de configuración, una consulta a un esquema— puede convertirse en un módulo que se importa como cualquier otro, con tree shaking, con tipos, con HMR. El byte nulo es la humilde pieza de infraestructura que hace esto seguro: un solo carácter, imposible en una ruta real, que le dice al bundler entero “trátame como código, no como archivo”. Comprender esto reordena tu manera de pensar los plugins: dejas de preguntarte qué archivos transformar y empiezas a preguntarte qué información quieres exponer como módulo. La generación de código deja de ser un paso previo, con sus scripts y sus artefactos versionados por error, y se vuelve parte del propio grafo, viva y siempre fresca. Es la misma idea que sostiene los frameworks modernos construidos sobre Vite, donde buena parte de lo que importas —rutas, layouts, datos— no existe en tu carpeta src sino que nace en la resolución. Quien domina los módulos virtuales ha entendido que el bundler no es un lector de archivos: es un generador de grafos, y el disco es solo una de sus fuentes.
- Escribe un plugin que exponga
virtual:build-infocon el commit y la fecha, usandoresolveIdyload. - Añade el prefijo
\0al id resuelto y explica, con un caso concreto, qué se rompería sin él. - Prefija el id público con el nombre de tu plugin y razona qué colisión evitas al hacerlo.
- Haz que el módulo lea datos de un YAML, declara la dependencia con
this.addWatchFilee invalídalo enhandleHotUpdate. - Localiza en un plugin real del ecosistema —iconos, PWA, rutas— dónde reconoce su id virtual y dónde lo carga.