Los hooks de Rollup: resolveId, load, transform
El corazón del sistema de plugins: los tres hooks de build que Vite hereda de Rollup y el ciclo por el que pasa cada módulo. resolveId como hook de primero que gana, load que entrega el código fuente, transform que lo reescribe en cadena, el contexto this con resolve, emitFile y addWatchFile, la cadena de sourcemaps y el grafo de módulos que se construye recursión a recursión.
Si el sistema de plugins es el producto, estos tres hooks son su latido. Cada módulo que entra en un build de Vite —tu código, una dependencia, un CSS, un SVG— recorre siempre la misma secuencia: primero se resuelve su identidad con resolveId, luego se carga su contenido con load, y por último se reescribe con transform. De ahí sale un fuente que Rollup parsea, cuyos imports vuelven a entrar por el mismo tubo, y así hasta tejer el grafo completo. Entender este ciclo es entender cómo un bundler convierte un import en bytes.
- Comprender
resolveIdcomo el hook que traduce un especificador a un id. - Ver
loadcomo el hook que entrega el código fuente de un id. - Encadenar transformaciones con
transformy respetar la cadena de sourcemaps. - Reconstruir el ciclo completo por módulo y el grafo que emerge de él.
resolveId: del especificador al id
Cuando el bundler encuentra un import './boton', esa cadena './boton' es un especificador, no un id: le falta extensión, no es una ruta absoluta, y ni siquiera tiene por qué corresponder a un archivo. El hook resolveId(source, importer, options) es quien traduce ese especificador al id canónico del módulo. Recibe el texto del import, quién lo importó, y opciones de contexto; devuelve un id, un objeto con metadatos, o null para ceder el turno.
resolveId(source, importer, options) {
if (source === 'virtual:datos') {
return '\0virtual:datos'
}
if (source.startsWith('#interno/')) {
return { id: reescribir(source, importer), moduleSideEffects: false }
}
return null
}
resolveId es un hook de tipo primero que gana: Rollup recorre los plugins en orden y se queda con el primer resultado no nulo, sin consultar a los siguientes. Por eso devolver null es la norma cuando el especificador no te incumbe; es tu forma de decir que otro plugin, o el resolutor por defecto, siga probando. Devolver un id lo marca como resuelto; devolver un objeto permite adjuntar información valiosa —external: true lo excluye del bundle, moduleSideEffects: false autoriza al tree shaking a eliminarlo si nadie usa sus exports—.
Dentro de un hook, this.resolve(source, importer) invoca la cadena completa de resolución, incluidos los demás plugins, y te devuelve el id que ganaría. Es la forma correcta de resolver a medias: reescribes el especificador y delegas el resto en la infraestructura, en vez de reimplementar a mano el algoritmo de Node. El truco de pasar { skipSelf: true } evita la recursión infinita de que tu propio resolveId se llame otra vez sobre el mismo especificador.
load: del id al código fuente
Con un id ya resuelto, Rollup necesita su contenido. El hook load(id) recibe ese id y devuelve el código fuente como cadena, un objeto { code, map }, o null para ceder. Como resolveId, es un hook de primero que gana: en cuanto un plugin devuelve algo, Rollup deja de preguntar y no se toca el disco.
load(id) {
if (id === '\0virtual:datos') {
return `export default ${JSON.stringify(datos)}`
}
return null
}
Aquí está la clave de todo lo que viene después: si nadie intercepta load, Rollup cae en su comportamiento por defecto y lee el archivo del disco por su ruta. Pero si un plugin devuelve el código antes, ese id nunca necesita existir como archivo. Ese es exactamente el mecanismo de los módulos virtuales que veremos en la lección 4: un resolveId que inventa un id con prefijo nulo y un load que genera su contenido al vuelo. load es la costura por la que el código puede entrar en el grafo sin pasar por el sistema de archivos.
transform: reescribir en cadena
Una vez cargado el fuente, transform(code, id) lo reescribe. Es donde se compila TypeScript a JavaScript, se procesan las plantillas de un componente, se inyectan variables o se instrumenta el código. A diferencia de los dos anteriores, transform es un hook secuencial: no gana el primero, corren todos, y la salida de uno es la entrada del siguiente. Una cadena de transformaciones, no una elección.
transform(code, id) {
if (!id.endsWith('.svg')) return null
const contenido = optimizarSvg(code)
return {
code: `export default ${JSON.stringify(contenido)}`,
map: null,
}
}
Esa naturaleza encadenada tiene una consecuencia que no puedes ignorar: los sourcemaps. Si tu transform altera las posiciones del código y devuelve map: null, rompes la cadena, y el navegador te enseñará el código transformado en lugar del que escribiste. Cuando la transformación es no trivial, debes generar y devolver un sourcemap que Rollup componga con los de las etapas anteriores. Solo puedes devolver map: null con la conciencia tranquila cuando no mueves posiciones —por ejemplo, si reemplazas el módulo entero por una línea generada, como en el ejemplo del SVG, donde el mapa original ya no significa nada—.
El error más silencioso de un plugin de transformación es romper los sourcemaps sin darse cuenta. El build sigue funcionando, los tests pasan, y solo meses después alguien abre el depurador y encuentra que las líneas no cuadran. Si tu transform reescribe partes del código conservando el resto, genera el mapa; hay utilidades como MagicString que rastrean los cambios y producen el sourcemap por ti. Trata map: null como una afirmación fuerte —“este código no mantiene relación posicional con el original”— y no como el valor cómodo por defecto.
El ciclo completo y el contexto this
Ata las tres piezas y aparece el ciclo de vida de un módulo. Rollup arranca por los puntos de entrada, y para cada uno recorre resolveId, load y transform. Con el fuente ya transformado, lo parsea a un AST, descubre sus imports y exports, y por cada import vuelve a empezar: resolveId, load, transform, recursión. El grafo de módulos no se declara en ningún sitio; emerge de repetir este ciclo hasta que no quedan imports sin visitar.
Esa recursión revela por qué importa la clasificación de los hooks en tres tipos. Los de primero que gana —resolveId, load— modelan decisiones: la identidad y el contenido de un módulo son únicos, así que en cuanto un plugin responde, la pregunta queda cerrada. Los secuenciales —transform— modelan una tubería: la reescritura es acumulativa, y tiene sentido que varios plugins la toquen en cadena. Y hay un tercer tipo, los paralelos, como buildStart y buildEnd, que marcan el principio y el final de la fase de build y donde el orden entre plugins no importa, así que Rollup los ejecuta a la vez. Saber a qué tipo pertenece un hook te dice de antemano si compites, colaboras o simplemente reaccionas a un momento del ciclo.
flowchart TD E[punto de entrada] --> R[resolveId da el id] R --> L[load da el codigo fuente] L --> T[transform reescribe el codigo] T --> P[Rollup parsea el AST] P --> D[descubre los imports] D -->|por cada import| R P --> G[grafo de modulos completo]
Dentro de cualquiera de estos hooks tienes acceso a un contexto rico a través de this, la ventana del plugin al bundler. this.resolve y this.load te dejan invocar la resolución y la carga de otro módulo desde dentro del tuyo. this.emitFile añade un chunk o un asset a la salida. this.addWatchFile le pide al dev server que vigile un archivo externo —una plantilla, un .env, un esquema— para reejecutar cuando cambie. this.getModuleInfo inspecciona los metadatos de cualquier módulo del grafo, y this.parse te da su AST sin reimplementar un parser. Y this.error y this.warn emiten diagnósticos con la ubicación correcta, integrados en el informe del build. Ese contexto es lo que distingue a un hook de una función suelta: no solo recibe datos, sino que puede pedirle cosas al bundler mientras corre.
resolveId · primero que gana
Traduce un especificador a un id. El primer resultado no nulo cierra la resolución; devolver null cede el turno.
load · primero que gana
Entrega el fuente de un id. Si nadie lo intercepta, Rollup lee el disco; interceptarlo abre la puerta a los módulos sin archivo.
transform · secuencial
Reescribe el código en cadena: corren todos los plugins y la salida de uno alimenta al siguiente, arrastrando el sourcemap.
this · el contexto
La ventana al bundler: resolve, load, emitFile, addWatchFile, getModuleInfo, parse, error y warn.
En 2026, con Rolldown como motor de Vite, estos hooks admiten un filtro declarativo: en vez de recibir todos los módulos y descartar los que no te incumben con un if, declaras por adelantado qué ids o qué contenidos te interesan, y el bundler ni siquiera llama a tu hook para el resto. La ganancia no es solo de claridad: como el núcleo es nativo, cada llamada a un hook en JavaScript cruza la frontera entre Rust y JavaScript, y filtrar en el lado nativo evita ese cruce para la inmensa mayoría de módulos. El patrón if (!id.endsWith(...)) return null sigue siendo válido, pero el filtro lo convierte en una declaración que el motor puede aprovechar antes de molestarte.
Si tu plugin genera código a partir de un archivo que no es un módulo del grafo —pongamos, un YAML de datos que lees en load—, el dev server no sabe que existe y no reaccionará a sus cambios. La solución es declararlo con this.addWatchFile(rutaDelYaml) durante la carga. A partir de ahí, editar ese archivo dispara una recarga, y tu módulo virtual se regenera con los datos nuevos. Es la diferencia entre un plugin que genera una vez y uno que vive con tu proyecto.
Detrás de la aparente complejidad de un build moderno hay una idea de una sencillez casi provocadora: todo se reduce a resolver una identidad, obtener un contenido y reescribirlo, en bucle. resolveId, load y transform no son tres funciones entre muchas; son las tres preguntas que un bundler se hace ante cada módulo —quién eres, qué contienes, en qué te conviertes— y el grafo entero no es más que el eco de repetirlas sobre cada import descubierto. Interiorizar esto cambia cómo lees el ecosistema. El plugin que carga imágenes, el que compila un lenguaje de plantillas, el que inyecta datos de build, el que soporta un formato inventado ayer: todos son variaciones del mismo patrón mínimo, interceptando una de las tres preguntas. Y como Rollup, Rolldown, Vite y sus primos comparten este contrato, un plugin bien escrito contra estos tres hooks es sorprendentemente portable entre herramientas. La distinción entre hooks de primero que gana y hooks secuenciales no es trivia de documentación: es la que decide si tu plugin compite por resolver o colabora en transformar. Quien entiende que un bundler es este ciclo, y no una caja negra, deja de configurar por superstición y empieza a razonar sobre lo que ocurre módulo a módulo. Ese es el salto de nivel: del usuario de plugins al autor que sabe exactamente dónde engancharse.
- Escribe un
resolveIdque reconozca un especificador propio y devuelva un id, y observa cómonullcede el turno a los demás. - Añade un
loadque devuelva código para ese id sin que exista ningún archivo, y confirma que el import funciona. - Escribe un
transformque reescriba archivos de una extensión inventada y razona por qué es secuencial y no de primero que gana. - Rompe a propósito los sourcemaps devolviendo
map: nulltras mover código, y compruébalo en el depurador del navegador. - Usa
this.addWatchFilepara vigilar un archivo externo y verifica que editarlo dispara una recarga en el dev server.