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

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

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

Es posible incluir referencias a algunos de esos recursos directamente en el código HTML, pero, a menudo, están vinculados lógicamente a componentes reutilizables. Por ejemplo, un 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 de WebAssembly vinculado a su elemento de 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 (o si) se carga el componente correspondiente.

Gráfico que visualiza varios tipos de recursos importados a JS.

Sin embargo, la mayoría de los proyectos grandes tienen sistemas de compilación que realizan optimizaciones adicionales y reorganizan el contenido, por ejemplo, el empaquetado y la reducción. No pueden ejecutar el código ni predecir cuál será el resultado de la ejecución, ni pueden recorrer todas las cadenas literales posibles en JavaScript y adivinar si es una URL de recurso o no. Entonces, ¿cómo puedes hacer que “vean” esos recursos dinámicos cargados por los componentes de JavaScript y los incluyan en la compilación?

Importaciones personalizadas en empaquetadores

Un enfoque común es reutilizar la sintaxis de importación estática. En algunos empaquetadores, es posible que se detecte automáticamente el formato según la extensión del 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 del empaquetador encuentra una importación con una extensión que reconoce o un esquema personalizado explícito (asset-url: y js-url: en el ejemplo anterior), agrega el recurso al que se hace referencia al gráfico de compilación, lo copia en el destino final, realiza las optimizaciones aplicables para el tipo de recurso y muestra la URL final que se usará durante el tiempo de ejecución.

Los beneficios de este enfoque: La reutilización de la sintaxis de importación de JavaScript garantiza que todas las URLs sean estáticas y relativas al archivo actual, lo que facilita la ubicación de esas dependencias para el sistema de compilación.

Sin embargo, tiene un inconveniente importante: ese código no puede funcionar directamente en el navegador, ya que este no sabe cómo controlar esos esquemas o extensiones de importación personalizados. Esto puede estar bien si controlas todo el código y, de todos modos, dependes de un empaquetador para el desarrollo, pero cada vez es más común usar módulos de JavaScript directamente en el navegador, al menos durante el desarrollo, para reducir los inconvenientes. Es posible que alguien que trabaje en una demo pequeña ni siquiera necesite un empaquetador, incluso en producción.

Patrón universal para navegadores y empaquetadores

Si trabajas en un componente reutilizable, querrás que funcione en cualquier entorno, ya sea que se use directamente en el navegador o se compile previamente como parte de una app más grande. La mayoría de los empaquetadores 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, pero 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 volver a escribir 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? Analicemos esto. El constructor new URL(...) toma una URL relativa como primer argumento y la resuelve en función de una URL absoluta proporcionada como segundo argumento. En nuestro caso, el segundo argumento es import.meta.url, que proporciona la URL del módulo de JavaScript actual, por lo 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 empaquetadores otorgan un tratamiento especial a un patrón con una URL estática import('./some-static-url.js') como una forma de procesar previamente una dependencia conocida en el tiempo de compilación, pero la dividen 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). Sin embargo, el patrón new URL('...', import.meta.url) es una señal clara para que los empaquetadores procesen previamente y, además, incluyan una dependencia junto con el código JavaScript principal.

URLs relativas ambiguas

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

El motivo es que, a diferencia de las sentencias de importación, las solicitudes dinámicas se resuelven 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, podría 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, sino que resuelve las URLs en relación con el documento. Como resultado, fetch('./module.wasm') intentaría cargar http://example.com/module.wasm en lugar de la http://example.com/src/module.wasm prevista y fallaría (o, peor aún, cargaría de forma silenciosa un recurso diferente del que deseas).

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 se cargará correctamente el módulo de WebAssembly esperado, además de proporcionar a los empaquetadores una forma de encontrar esas rutas de acceso relativas durante el tiempo de compilación.

Compatibilidad con herramientas

Empaquetadores

Los siguientes empaquetadores ya admiten el esquema new URL:

WebAssembly

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

C/C++ a través de Emscripten

Cuando usas Emscripten, puedes pedirle que emita el código de enlace 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 uses esta opción, el resultado usará el patrón new URL(..., import.meta.url) de forma interna, de modo que los empaquetadores puedan encontrar el archivo Wasm asociado automáticamente.

También puedes usar esta opción con subprocesos de WebAssembly si agregas 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 Web Worker generado se incluirá de la misma manera y los compiladores y los navegadores también podrán encontrarlo.

Rust a través de 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 JavaScript que se basa en la propuesta de integración de ESM de WebAssembly. En el momento de escribir este artículo, esta propuesta aún es experimental, y el resultado solo funcionará cuando se combine 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 empaquetadores 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 arbitrarias, pero si usas Rayon, puedes combinarlo con el adaptador wasm-bindgen-rayon para que pueda generar trabajadores en la Web. El elemento de unión de JavaScript que usa wasm-bindgen-rayon también incluye el patrón new URL(...) en segundo plano, por lo que los agrupadores también podrán descubrir y, además, incluir los trabajadores.

Funciones futuras

import.meta.resolve

Una llamada import.meta.resolve(...) dedicada es una posible mejora futura. Permitiría resolver los especificadores en relación con el módulo actual de una manera más directa, 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 solucionadores 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 empaquetadores, ya que es una sintaxis estática que no depende de las APIs del entorno de ejecución, como URL.

import.meta.resolve ya está implementado como un experimento en Node.js, pero aún quedan algunas preguntas sin resolver sobre cómo debería funcionar en la Web.

Aserciones de importación

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 usarlos y reemplazar los casos de uso que actualmente cubre el patrón new URL, pero los tipos en las aserciones de importación se agregan de forma individual. Por ahora, solo abarcan JSON, y pronto se lanzarán módulos de CSS, pero otros tipos de recursos aún requerirán una solución más genérica.

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

Conclusión

Como puedes ver, existen varias formas de incluir recursos que no son de JavaScript en la Web, pero tienen varias desventajas y no funcionan en varias cadenas de herramientas. Es posible que las propuestas futuras nos permitan importar esos recursos con sintaxis especializada, pero aún no hemos llegado a ese punto.

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