Cómo crear paquetes de recursos que no son de JavaScript

Obtén información para importar y agrupar varios tipos de elementos desde JavaScript.

Supongamos que estás trabajando en una app web. En ese caso, es probable que tengas que trabajar no solo con los módulos de JavaScript, sino también con todo tipo de otros recursos: Web Workers (que también son JavaScript, pero no parte del gráfico de módulos normal), imágenes, hojas de estilo, fuentes, módulos WebAssembly y otros.

Es posible incluir referencias a algunos de esos recursos directamente en el código HTML, pero a menudo se vinculan de forma lógica a componentes reutilizables. Por ejemplo, una hoja de estilo para un menú desplegable personalizado vinculado a su parte de JavaScript, imágenes de íconos vinculadas a un componente de barra de herramientas o un módulo WebAssembly vinculado a su unión de JavaScript. En esos casos, es más conveniente hacer referencia a los recursos directamente desde sus módulos de JavaScript y cargarlos de forma dinámica cuando se carga (o si) el componente correspondiente.

Gráfico en el que se visualizan varios tipos de recursos importados a JS.

Sin embargo, la mayoría de los proyectos grandes tienen sistemas de compilación que realizan optimizaciones y reorganización adicionales del contenido, por ejemplo, agrupamiento y reducción. No pueden ejecutar el código ni predecir cuál será el resultado de la ejecución, ni pueden atravesar todos los literales de cadena posibles en JavaScript y hacer conjeturas sobre si es una URL de recurso o no. Entonces, ¿cómo puedes hacer que "vean" esos elementos dinámicos cargados por los componentes de JavaScript e incluirlos en la compilación?

Importaciones personalizadas en agrupadores

Un enfoque común consiste en reutilizar la sintaxis de importación estática. En algunos agrupadores, es posible que detecte automáticamente el formato por la extensión de archivo, mientras que otros permiten que los complementos usen un esquema de URL personalizado, como en el siguiente ejemplo:

// regular JavaScript import
import { loadImg } from './utils.js';

// special "URL imports" for assets
import imageUrl from 'asset-url:./image.png';
import wasmUrl from 'asset-url:./module.wasm';
import workerUrl from 'js-url:./worker.js';

loadImg(imageUrl);
WebAssembly.instantiateStreaming(fetch(wasmUrl));
new Worker(workerUrl);

Cuando un complemento de agrupador encuentra una importación con una extensión que reconoce o con un esquema personalizado explícito (en el ejemplo anterior, asset-url: y js-url:), agrega el recurso al que se hace referencia al gráfico de compilación, lo copia en el destino final, realiza optimizaciones aplicables al tipo de recurso y muestra la URL final que se usará durante el tiempo de ejecución.

Los beneficios de este enfoque: reutilizar la sintaxis de importación de JavaScript garantiza que todas las URL sean estáticas y relacionadas con el archivo actual, lo que facilita la ubicación de esas dependencias para el sistema de compilación.

Sin embargo, presenta una desventaja importante: este código no puede funcionar directamente en el navegador, ya que este no sabe cómo manejar esos esquemas o extensiones de importación personalizados. Esto podría estar bien si controlas todo el código y dependes de un agrupador para el desarrollo, pero es cada vez más común usar módulos de JavaScript directamente en el navegador, al menos durante el desarrollo, para reducir la fricción. Es posible que alguien que esté trabajando en una demostración pequeña ni siquiera necesite un agrupador en absoluto, ni siquiera en producción.

Patrón universal para navegadores y agrupadores

Si trabajas en un componente reutilizable, querrás que funcione en cualquiera de los dos entornos, ya sea que se use directamente en el navegador o se precompilen como parte de una app más grande. La mayoría de los agrupadores modernos permiten esto aceptando el siguiente patrón en los módulos de JavaScript:

new URL('./relative-path', import.meta.url)

Las herramientas pueden detectar este patrón de forma estática, casi como si fuera una sintaxis especial, aunque también es una expresión válida de JavaScript que también funciona directamente en el navegador.

Cuando se usa este patrón, el ejemplo anterior se puede reescribir de la siguiente manera:

// regular JavaScript import
import { loadImg } from './utils.js';

loadImg(new URL('./image.png', import.meta.url));
WebAssembly.instantiateStreaming(
  fetch(new URL('./module.wasm', import.meta.url)),
  { /* … */ }
);
new Worker(new URL('./worker.js', import.meta.url));

¿Cómo funciona? Veámoslo. El constructor new URL(...) toma una URL relativa como primer argumento y la resuelve en comparación con una URL absoluta proporcionada como el segundo argumento. En nuestro caso, el segundo argumento es import.meta.url, que proporciona la URL del módulo de JavaScript actual, de modo que el primer argumento puede ser cualquier ruta relativa a él.

Tiene compensaciones similares a las de la importación dinámica. Si bien es posible usar import(...) con expresiones arbitrarias, como import(someUrl), los agrupadores dan un tratamiento especial a un patrón con la URL estática import('./some-static-url.js') como una forma de procesar previamente una dependencia conocida en el tiempo de compilación, pero dividirla en su propio fragmento que se carga de forma dinámica.

De manera similar, puedes usar new URL(...) con expresiones arbitrarias, como new URL(relativeUrl, customAbsoluteBase), aunque el patrón new URL('...', import.meta.url) es un indicador claro para que los agrupadores realicen el procesamiento previo e incluyan una dependencia junto con el JavaScript principal.

URLs relativas ambiguas

Es posible que te preguntes por qué los agrupadores no pueden detectar otros patrones comunes, por ejemplo, fetch('./module.wasm') sin los wrappers new URL.

Esto se debe a que, a diferencia de las sentencias de importación, cualquier solicitud dinámica se resuelve en relación con el documento en sí y no con el archivo JavaScript actual. Supongamos que tienes la siguiente estructura:

  • index.html:
    html <script src="src/main.js" type="module"></script>
  • src/
    • main.js
    • module.wasm

Si quieres cargar module.wasm desde main.js, puede ser tentador usar una ruta de acceso relativa, como fetch('./module.wasm').

Sin embargo, fetch no conoce la URL del archivo JavaScript en el que se ejecuta. En cambio, resuelve las URLs relativas al documento. Como resultado, fetch('./module.wasm') terminaría intentando cargar http://example.com/module.wasm en lugar del http://example.com/src/module.wasm deseado y fallará (o, peor aún, cargaría de forma silenciosa un recurso diferente del deseado).

Si unes la URL relativa en new URL('...', import.meta.url), puedes evitar este problema y garantizar que cualquier URL proporcionada se resuelva en relación con la URL del módulo de JavaScript actual (import.meta.url) antes de que se pase a cualquier cargador.

Reemplaza fetch('./module.wasm') por fetch(new URL('./module.wasm', import.meta.url)) y cargará correctamente el módulo de WebAssembly esperado, y brindará a los agrupadores una forma de encontrar esas rutas de acceso relativas durante el tiempo de compilación.

Compatibilidad con herramientas

Agrupadores

Los siguientes agrupadores ya admiten el esquema new URL:

WebAssembly

Cuando trabajes con WebAssembly, por lo general, no cargarás el módulo de Wasm de forma manual, sino importarás la unión de JavaScript que emite la cadena de herramientas. Las siguientes cadenas de herramientas pueden emitir el patrón new URL(...) descrito de forma interna.

C/C++ mediante Emscripten

Al usar Emscripten, puedes pedirle que emita la adhesión de JavaScript como un módulo ES6 en lugar de una secuencia de comandos normal a través de una de las siguientes opciones:

$ emcc input.cpp -o output.mjs
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6

Cuando se usa esta opción, el resultado usará el patrón new URL(..., import.meta.url) de forma interna para que los agrupadores puedan encontrar el archivo Wasm asociado automáticamente.

También puedes usar esta opción con subprocesos de WebAssembly agregando una marca -pthread:

$ emcc input.cpp -o output.mjs -pthread
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread

En este caso, el trabajador web generado se incluirá de la misma manera y también será detectable para los agrupadores y los navegadores.

Rust mediante wasm-pack o wasm-bindgen

wasm-pack, la cadena de herramientas principal de Rust para WebAssembly, también tiene varios modos de salida.

De forma predeterminada, emitirá un módulo de JavaScript que se basa en la propuesta de integración de ESM de WebAssembly. Por el momento, esta propuesta aún es experimental, y el resultado funcionará solo cuando se incluya en el paquete con Webpack.

En su lugar, puedes pedirle a Wasm-pack que emita un módulo ES6 compatible con el navegador a través de --target web:

$ wasm-pack build --target web

El resultado usará el patrón new URL(..., import.meta.url) descrito, y los agrupadores también descubrirán automáticamente el archivo Wasm.

Si quieres usar subprocesos de WebAssembly con Rust, la historia es un poco más complicada. Consulta la sección correspondiente de la guía para obtener más información.

La versión corta es que no puedes usar APIs de subprocesos arbitrarios, pero si usas Rayon, puedes combinarlo con el adaptador wasm-bindgen-rayon para que pueda generar trabajadores en la Web. La unión de JavaScript que usa wasm-bindgen-rayon también incluye el patrón new URL(...) de forma interna, por lo que los agrupadores podrán detectar y también incluir los trabajadores.

Funciones futuras

import.meta.resolve

Una llamada exclusiva a import.meta.resolve(...) es una posible mejora a futuro. Permitiría resolver especificadores relativos al módulo actual de una manera más sencilla y sin parámetros adicionales:

new URL('...', import.meta.url)
await import.meta.resolve('...')

También se integraría mejor con los mapas de importación y los agentes de resolución personalizados, ya que pasaría por el mismo sistema de resolución de módulos que import. También sería un indicador más sólido para los agrupadores, ya que es una sintaxis estática que no depende de APIs de entorno de ejecución como URL.

import.meta.resolve ya se implementó como un experimento en Node.js, pero todavía hay algunas preguntas sin resolver sobre cómo debería funcionar en la Web.

Cómo importar aserciones

Las aserciones de importación son una función nueva que permite importar tipos distintos de módulos de ECMAScript. Por ahora, se limitan a JSON:

foo.json:

{ "answer": 42 }

main.mjs:

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

Los agrupadores también pueden usarlas para reemplazar los casos de uso cubiertos actualmente por el patrón new URL, pero los tipos en las aserciones de importación se agregan por caso. Por ahora, solo se aplica a JSON, y próximamente se incluirán módulos de CSS, pero otros tipos de recursos seguirán requiriendo una solución más genérica.

Consulta la explicación de la función de v8.dev para obtener más información sobre esta función.

Conclusión

Como puedes ver, existen varias maneras de incluir en la Web recursos que no son de JavaScript, pero presentan varias desventajas y no funcionan en varios conjuntos de herramientas. Es posible que las propuestas futuras nos permitan importar tales recursos con una sintaxis especializada, pero aún no estamos listos.

Hasta entonces, el patrón new URL(..., import.meta.url) es la solución más prometedora que ya funciona en navegadores, varios agrupadores y cadenas de herramientas de WebAssembly en la actualidad.