wandres.dev
RESOLUCIÓN DE MÓDULOS · exports e imports

Cómo los bundlers extienden la resolución

resolve.alias, extensiones, mainFields y conditions personalizadas: cómo Vite, Rolldown, webpack y el oxc-resolver reescriben y amplían el algoritmo de Node para el mundo del bundling en 2026.

⏱ 18 min

Node define un algoritmo de resolución; los bundlers lo toman como base y lo extienden. Un bundler necesita resolver .ts, .vue o .svelte, aplicar alias de proyecto, preferir el campo module sobre main e inyectar sus propias conditions. Esta lección cierra el nivel: dónde y por qué el resolutor del bundler diverge del de Node, cómo se configura cada eje, y por qué en 2026 casi todo el ecosistema comparte el mismo motor de resolución escrito en Rust.

🎯 Al terminar esta lección sabrás
  • Ver dónde y por qué el resolutor del bundler diverge del de Node.
  • Configurar los ejes de alias, extensiones y mainFields.
  • Añadir y priorizar conditions personalizadas en el bundler.
  • Situar el oxc-resolver en el stack de resolución de 2026.

Un algoritmo, muchas implementaciones

Node trae su resolutor incorporado; cada bundler trae el suyo, compatible con exports, imports y conditions, pero ampliado con ejes que Node no necesita. Históricamente webpack usa enhanced-resolve; esbuild lleva el suyo en Go; Vite mantenía uno propio y, con Vite 8 sobre Rolldown, adopta el oxc-resolver escrito en Rust dentro del proyecto Oxc, el mismo que usa Rspack. Todos implementan el estándar de Node y luego lo extienden por los mismos cuatro ejes.

La compatibilidad con el estándar es la parte fácil; la diferencia real está en las extensiones y en el rendimiento. La resolución es una de las operaciones más repetidas de un build —miles de imports, cada uno con varios intentos de disco— así que reescribirla en un lenguaje compilado tiene un impacto medible en el tiempo total. Ese es el motor detrás de la migración de un resolutor en JavaScript a uno en Rust que domina el ecosistema de 2026.

No todos los resolutores coinciden en los detalles. esbuild, por su diseño orientado a la velocidad, expone opciones propias como conditions y resolveExtensions, y ha tenido históricamente un soporte más parcial de algunos casos raros de exports. Saber qué resolutor usa cada eslabón de tu cadena —esbuild en el pre-bundle de dependencias de Vite, oxc-resolver en el build— explica por qué un caso límite puede comportarse distinto en desarrollo y en producción.

flowchart LR
A[especificador] --> B[aplicar alias]
B --> C[probar extensiones]
C --> D[leer package.json]
D --> E[elegir por mainFields y conditions]
E --> F[ruta final del modulo]

Los ejes de configuración

En Vite, los cuatro ejes viven bajo la clave resolve. Cada uno reescribe una parte del algoritmo base de Node:

// vite.config.js
export default {
  resolve: {
    alias: {
      "@": "/src",
      "@components": "/src/components"
    },
    extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json"],
    mainFields: ["browser", "module", "jsnext:main", "main"],
    conditions: ["browser", "development"],
    dedupe: ["react", "react-dom"]
  }
}
  • alias reescribe un especificador antes de resolverlo, de forma exacta o por patrón. Es potente y peligroso: si apuntas dentro de un paquete, te saltas su exports y rompes su encapsulación.
  • extensions define qué extensiones probar cuando el import las omite. El orden importa y tiene coste: cada extensión es un intento de disco. Menos entradas resuelven más rápido, y escribir siempre la extensión en tus imports elimina el problema de raíz.
  • mainFields decide qué campos del package.json probar antes de caer en exports o main. El clásico module sirve ESM a los bundlers; browser sustituye builds enteros. Con exports presente, este eje pierde protagonismo.
  • dedupe fuerza una sola copia de un paquete: es el dual instance hazard de la lección anterior, aplicado a React, resuelto en la resolución.

Hay un quinto factor silencioso: los enlaces simbólicos. En un monorepo con pnpm o con workspaces, los paquetes locales son symlinks, y el resolutor debe decidir si resuelve un módulo por su ruta real o por la del enlace. La opción preserveSymlinks cambia esa política y puede alterar qué copia de una dependencia se elige. Por defecto conviene dejar que el resolutor siga el enlace hasta el archivo real, que es justo lo que hace posible que dedupe funcione dentro de un monorepo.

ℹ️
El resolutor del cliente y el del servidor deben coincidir en SSR

En aplicaciones con render en servidor conviven dos pasadas de resolución: la del cliente, que empaqueta para el navegador, y la del servidor, que a menudo deja dependencias como externas para que Node las cargue en runtime. Si las conditions de ambas no están alineadas —resolve.conditions para el cliente y ssr.resolve.conditions o resolve.externalConditions para el servidor— acabas sirviendo el build de navegador en Node, o al revés. Alinear las dos pasadas es una de las causas más comunes de bugs sutiles en SSR.

Conditions en el bundler

El bundler, no el paquete, decide qué conjunto de conditions está activo, y suele separar cliente de servidor. En Vite tienes resolve.conditions para el cliente y ssr.resolve.conditions para el servidor; en webpack, resolve.conditionNames. Puedes además inyectar conditions propias para servir variantes internas:

// webpack: activar una condition propia "internal" y priorizar "module"
module.exports = {
  resolve: {
    conditionNames: ["internal", "import", "module", "browser", "default"],
    mainFields: ["module", "browser", "main"]
  }
}

Con eso, un paquete del monorepo puede exponer "internal": "./src/index.ts" en su exports y el bundler consumirá el fuente TypeScript directamente en desarrollo, saltándose el paso de build. Es el patrón de los internal packages o just-in-time packages, hoy omnipresente en los monorepos con Turborepo de 2026: publicas el compilado para el exterior y sirves el fuente para el interior, con una sola condition de diferencia.

Esa diferencia tiene un impacto enorme en la experiencia de desarrollo del monorepo: los cambios en un paquete interno se ven al instante, sin un paso de build intermedio, porque el bundler consume el fuente directamente. En producción, el mismo paquete se consume compilado y optimizado. Una única condition separa así el mundo rápido de desarrollo del mundo afinado de producción, sin duplicar código ni configuración.

Más allá de la configuración declarativa, los plugins pueden intervenir la resolución con un hook resolveId: es como los bundlers implementan los módulos virtuales, los imports de CSS o SVG, y alias dinámicos que ninguna tabla estática podría expresar. El resolutor del bundler es, por eso, a la vez un motor compatible con Node y un punto de extensión abierto a plugins, y esa doble naturaleza es lo que le permite entender un ecosistema de formatos que Node nunca contempló.

El resolutor es la costura donde se pega todo el ecosistema

La resolución es el punto exacto donde el package manager, el formato de módulo, TypeScript, el navegador y el bundler tienen que ponerse de acuerdo sobre qué archivo cargar. Es una costura fina y crítica: un fallo aquí no da un error claro, da el módulo equivocado. Por eso en 2026 la industria convergió en un motor común, el oxc-resolver escrito en Rust, hecho una vez y reutilizado por Rolldown (el bundler de Vite 8), por Rspack y por una lista creciente de herramientas, retirando al veterano enhanced-resolve escrito en JavaScript. Un único resolutor, rápido y correcto, tiene una consecuencia enorme: un paquete que resuelve bien en Vite resuelve igual en Rspack, y los bugs sutiles de exports o de conditions se arreglan en un solo lugar en vez de reimplementarse mal cinco veces. Esa es la lección profunda de todo el nivel: la resolución dejó de ser un detalle interno de cada herramienta para volverse infraestructura compartida del ecosistema. Dominar alias, extensions, mainFields y conditions es dominar la última pieza que hay entre tu import y el byte que ejecuta el navegador.

El resolutor de 2026

La foto de 2026 se resume en cuatro piezas y una recomendación transversal: minimiza los alias propietarios. Cuanto más delegues en exports, imports y las conditions estándar, menos configuración de bundler tendrás que mantener sincronizada a mano, y más portable será tu proyecto entre herramientas. La configuración de resolución más robusta es, casi siempre, la más pequeña.

El caso que más se resiste a esa simplicidad son los paths del tsconfig, que solo ve el type-checker:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Para que el bundler resuelva ese mismo @/, hay que replicarlo en resolve.alias, instalar vite-tsconfig-paths, o, lo preferible, migrar esos alias internos a #imports, que no necesita ningún espejo. Cada mecanismo que eliminas es una fuente de desincronización menos entre el type-checker, el bundler y el runtime.

🦀

oxc-resolver

El motor en Rust de Rolldown y Rspack, compatible con la resolución de Node y muy por encima en velocidad de enhanced-resolve.

Vite 8 sobre Rolldown

Unifica desarrollo y build bajo un mismo resolutor, reduciendo las discrepancias entre lo que ves en dev y lo que se empaqueta.

🧭

tsconfig paths

El bundler no los ve por sí solo: usa vite-tsconfig-paths, replícalos en alias, o mejor migra a #imports.

🧬

mainFields en retirada

Con exports universal, el peso vuelve a las conditions; mainFields queda para consumir paquetes heredados.

💡
Cuando dudes, activa el modo verboso

Ante una resolución que te desconcierta en el bundler, no la deduzcas: trázala. Vite acepta --debug para registrar la resolución de cada módulo, y los mensajes muestran qué alias, qué condition y qué extensión ganaron en cada import. Es la misma disciplina de la primera lección aplicada al bundler: instrumenta el resolutor real que corre, no el que crees que corre.

⚔️ Extiende la resolución
  1. Añade un alias @ que apunte a /src en Vite y comprueba que un import profundo se acorta y sigue resolviendo.
  2. Reduce resolve.extensions al mínimo, mide el efecto, y luego escribe siempre la extensión en tus imports.
  3. Define una condition personalizada internal y consume el fuente TypeScript de un paquete del monorepo sin compilarlo.
  4. Explica por qué el ecosistema de 2026 comparte un solo resolutor en Rust y qué clase de bugs elimina esa decisión.