Raggruppamento di risorse non JavaScript

Scopri come importare e raggruppare vari tipi di asset da JavaScript.

Ingvar Stepanyan
Ingvar Stepanyan

Supponiamo che tu stia lavorando a un'app web. In questo caso, è probabile che tu debba gestire non solo i moduli JavaScript, ma anche tutti i tipi di altre risorse: web worker (che sono anche JavaScript, ma non fanno parte del grafico dei moduli normale), immagini, fogli di stile, caratteri, moduli WebAssembly e altri ancora.

È possibile includere riferimenti ad alcune di queste risorse direttamente nel codice HTML, ma spesso sono accoppiate logicamente a componenti riutilizzabili. Ad esempio, un foglio di stile per un menu a discesa personalizzato collegato alla relativa parte JavaScript, immagini di icone collegate a un componente della barra degli strumenti o un modulo WebAssembly collegato al relativo collegamento JavaScript. In questi casi, è più pratico fare riferimento alle risorse direttamente dai relativi moduli JavaScript e caricarle dinamicamente quando (o se) viene caricato il componente corrispondente.

Grafico che mostra vari tipi di asset importati in JS.

Tuttavia, la maggior parte dei progetti di grandi dimensioni dispone di sistemi di compilazione che eseguono ulteriori ottimizzazioni e riorganizzazione dei contenuti, ad esempio il raggruppamento e la minimizzazione. Non possono eseguire il codice e prevedere quale sarà il risultato dell'esecuzione, né possono esaminare ogni possibile stringa letterale in JavaScript e fare supposizioni sul fatto che si tratti o meno di un URL di risorsa. Quindi, come puoi fare in modo che "vedano" gli asset dinamici caricati dai componenti JavaScript e li includano nella compilazione?

Importazioni personalizzate nei bundler

Un approccio comune è riutilizzare la sintassi di importazione statica. In alcuni bundler potrebbe rilevare automaticamente il formato in base all'estensione del file, mentre altri consentono ai plug-in di utilizzare uno schema URL personalizzato come nell'esempio seguente:

// 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);

Quando un plug-in del bundler trova un'importazione con un'estensione riconosciuta o uno schema personalizzato esplicito (asset-url: e js-url: nell'esempio precedente), aggiunge l'asset a cui si fa riferimento al grafo di compilazione, lo copia nella destinazione finale, esegue le ottimizzazioni applicabili al tipo di asset e restituisce l'URL finale da utilizzare in fase di esecuzione.

I vantaggi di questo approccio: riutilizzare la sintassi di importazione di JavaScript garantisce che tutti gli URL siano statici e relativi al file corrente, il che semplifica l'individuazione di queste dipendenze per il sistema di compilazione.

Tuttavia, presenta uno svantaggio significativo: questo codice non può funzionare direttamente nel browser, in quanto il browser non sa come gestire questi schemi o estensioni di importazione personalizzati. Questo potrebbe andare bene se controlli tutto il codice e ti affidi comunque a un bundler per lo sviluppo, ma è sempre più comune utilizzare i moduli JavaScript direttamente nel browser, almeno durante lo sviluppo, per ridurre l'attrito. Chi lavora a una piccola demo potrebbe non aver bisogno di un bundler, nemmeno in produzione.

Pattern universale per browser e bundler

Se lavori su un componente riutilizzabile, è opportuno che funzioni in uno dei due ambienti, sia che sia utilizzato direttamente nel browser o predisposto come parte di un'app più grande. La maggior parte dei bundler moderni consente questa operazione accettando il seguente pattern nei moduli JavaScript:

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

Questo pattern può essere rilevato in modo statico dagli strumenti, quasi come se fosse una sintassi speciale, ma è un'espressione JavaScript valida che funziona anche direttamente nel browser.

Quando utilizzi questo pattern, l'esempio riportato sopra può essere riscritto come:

// 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));

Come funziona? Scomponiamolo. Il costruttore new URL(...) prende un URL relativo come primo argomento e lo risolve in base a un URL assoluto fornito come secondo argomento. Nel nostro caso, il secondo argomento è import.meta.url che fornisce l'URL del modulo JavaScript corrente, pertanto il primo argomento può essere qualsiasi percorso relativo.

Presenta compromessi simili all'importazione dinamica. Sebbene sia possibile utilizzare import(...) con espressioni arbitrarie come import(someUrl), i bundler applicano un trattamento speciale a un pattern con URL statico import('./some-static-url.js') per pre-elaborare una dipendenza nota in fase di compilazione, ma suddividerla in un proprio chunk che viene caricato in modo dinamico.

Allo stesso modo, puoi utilizzare new URL(...) con espressioni arbitrarie come new URL(relativeUrl, customAbsoluteBase), ma il pattern new URL('...', import.meta.url) è un segnale chiaro per i bundler di eseguire la preelaborazione e includere una dipendenza insieme al codice JavaScript principale.

URL relativi ambigui

Forse ti starai chiedendo perché i bundler non riescono a rilevare altri pattern comuni, ad esempio fetch('./module.wasm') senza i wrapper new URL?

Il motivo è che, a differenza delle istruzioni di importazione, le eventuali richieste dinamiche vengono risolte in base al documento stesso e non al file JavaScript corrente. Supponiamo che tu abbia la seguente struttura:

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

Se vuoi caricare module.wasm da main.js, potrebbe essere allettante utilizzare un percorso relativo come fetch('./module.wasm').

Tuttavia, fetch non conosce l'URL del file JavaScript in cui viene eseguito, ma risolve gli URL in base al documento. Di conseguenza, fetch('./module.wasm') finirebbe per provare a caricare http://example.com/module.wasm anziché http://example.com/src/module.wasm e non andrebbe a buon fine (o, peggio, caricherebbe silenziosamente una risorsa diversa da quella prevista).

Inserendo l'URL relativo in new URL('...', import.meta.url) puoi evitare questo problema e garantire che qualsiasi URL fornito venga risolto in base all'URL del modulo JavaScript corrente (import.meta.url) prima di essere trasmesso a eventuali caricatori.

Sostituisci fetch('./module.wasm') con fetch(new URL('./module.wasm', import.meta.url)) e il modulo WebAssembly previsto verrà caricato correttamente. Inoltre, i bundler avranno un modo per trovare questi percorsi relativi anche durante il tempo di compilazione.

Supporto degli strumenti

Costruttori

I seguenti pacchetti già supportano lo schema new URL:

WebAssembly

Quando lavori con WebAssembly, in genere non carichi il modulo Wasm manualmente, ma importi il collegamento JavaScript emesso dalla toolchain. Le seguenti toolchain possono emettere automaticamente il pattern new URL(...) descritto di seguito.

C/C++ tramite Emscripten

Quando utilizzi Emscripten, puoi chiedergli di emettere il codice JavaScript come modulo ES6 anziché come script normale tramite una delle seguenti opzioni:

$ 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

Quando utilizzi questa opzione, l'output utilizzerà il pattern new URL(..., import.meta.url) sotto il cofano, in modo che i bundler possano trovare automaticamente il file Wasm associato.

Puoi utilizzare questa opzione anche con i thread WebAssembly aggiungendo un flag -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

In questo caso, il web worker generato verrà incluso nello stesso modo e sarà rilevabile anche da bundler e browser.

Ruggine tramite wasm-pack / wasm-bindgen

Anche wasm-pack, la toolchain Rust principale per WebAssembly, ha diverse modalità di output.

Per impostazione predefinita, emetterà un modulo JavaScript che si basa sulla proposta di integrazione ESM di WebAssembly. Al momento della stesura di questo documento, questa proposta è ancora in fase sperimentale e il risultato funzionerà solo se in bundle con Webpack.

In alternativa, puoi chiedere a wasm-pack di emettere un modulo ES6 compatibile con i browser tramite --target web:

$ wasm-pack build --target web

L'output utilizzerà il pattern new URL(..., import.meta.url) descritto e il file Wasm verrà rilevato automaticamente anche dai bundler.

Se vuoi utilizzare i thread WebAssembly con Rust, la situazione è un po' più complicata. Per ulteriori informazioni, consulta la sezione corrispondente della guida.

In breve, non puoi utilizzare API thread arbitrarie, ma se utilizzi Rayon, puoi combinarlo con l'adattatore wasm-bindgen-rayon in modo che possa generare worker sul web. La colla JavaScript utilizzata da wasm-bindgen-rayon include anche il pattern new URL(...) in background, quindi i worker saranno rilevabili e inclusi anche dai bundler.

Funzionalità future

import.meta.resolve

Una chiamata import.meta.resolve(...) dedicata è un potenziale miglioramento futuro. Consentirebbe di risolvere gli specificatori rispetto al modulo corrente in modo più semplice, senza parametri aggiuntivi:

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

Inoltre, si integrerebbe meglio con le mappe di importazione e i risolutori personalizzati, in quanto utilizzerebbe lo stesso sistema di risoluzione dei moduli di import. Sarebbe un segnale più forte anche per i bundler, in quanto si tratta di una sintassi statica che non dipende da API di runtime come URL.

import.meta.resolve è già implementato come esperimento in Node.js, ma ci sono ancora alcune domande irrisolte su come dovrebbe funzionare sul web.

Importa asserzioni

Le asserzioni di importazione sono una nuova funzionalità che consente di importare tipi diversi dai moduli ECMAScript. Per il momento sono limitati a JSON:

foo.json:

{ "answer": 42 }

main.mjs:

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

Potrebbero essere utilizzati anche dai bundler e sostituire i casi d'uso attualmente coperti dal pattern new URL, ma i tipi nelle asserzioni di importazione vengono aggiunti caso per caso. Per il momento coprono solo JSON, ma a breve saranno disponibili i moduli CSS. Tuttavia, altri tipi di asset richiederanno ancora una soluzione più generica.

Consulta la spiegazione della funzionalità v8.dev per saperne di più su questa funzionalità.

Conclusione

Come puoi vedere, esistono vari modi per includere risorse non JavaScript sul web, ma presentano vari svantaggi e non funzionano su varie toolchain. Le proposte future potrebbero permetterci di importare questi asset con una sintassi specializzata, ma non ci siamo ancora.

Fino ad allora, il pattern new URL(..., import.meta.url) è la soluzione più promettente che funziona già oggi nei browser, in vari bundler e nelle toolchain WebAssembly.