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 su 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 che non fanno parte del grafico di modulo standard), immagini, fogli di stile, caratteri, moduli WebAssembly e altri.

È possibile includere riferimenti ad alcune di queste risorse direttamente nel codice HTML, ma spesso sono logicamente associati a componenti riutilizzabili. Ad esempio, un foglio di stile per un menu a discesa personalizzato collegato alla sua parte JavaScript, immagini di icone collegate a un componente della barra degli strumenti o un modulo WebAssembly collegato al relativo collante JavaScript. In questi casi, è più pratico fare riferimento alle risorse direttamente dai 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 ha dei sistemi di creazione che eseguono ulteriori ottimizzazioni e riorganizzazioni dei contenuti, come il raggruppamento e la minimizzazione. Non sono in grado di eseguire il codice e prevedere quale sarà il risultato dell'esecuzione, né di attraversare ogni possibile valore letterale di stringa in JavaScript e di indovinare se si tratta o meno di un URL di risorsa. Quindi, come puoi fargli "vedere" gli asset dinamici caricati dai componenti JavaScript e includerli nella build?

Importazioni personalizzate nei bundle

Un approccio comune consiste nel riutilizzare la sintassi di importazione statica. In alcuni bundle 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 bundler trova un'importazione con un'estensione che riconosce o con uno schema personalizzato esplicito (asset-url: e js-url: nell'esempio sopra riportato), aggiunge l'asset di riferimento al grafico di build, lo copia nella destinazione finale, esegue le ottimizzazioni applicabili al tipo di asset e restituisce l'URL finale da utilizzare durante il runtime.

I vantaggi di questo approccio: il riutilizzo della 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 quest'ultimo non sa come gestire le estensioni o gli schemi 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 usare moduli JavaScript direttamente nel browser, almeno in fase di sviluppo, per ridurre l'attrito. Chi lavora su una piccola demo potrebbe non avere nemmeno bisogno di un bundler, anche in produzione.

Pattern universale per browser e bundler

Se stai lavorando a un componente riutilizzabile, ti consigliamo di farlo in entrambi gli ambienti, che venga utilizzato direttamente nel browser o predefinito come parte di un'app più grande. La maggior parte dei bundle moderni lo consente 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 precedente 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, quindi il primo argomento può essere qualsiasi percorso relativo.

Presenta compromessi simili a quelli dell'importazione dinamica. Sebbene sia possibile usare import(...) con espressioni arbitrarie come import(someUrl), i bundler danno un trattamento speciale a un pattern con URL statico import('./some-static-url.js') come modo per pre-elaborare una dipendenza nota in fase di compilazione, ma suddivise in un proprio blocco che viene caricato dinamicamente.

Allo stesso modo, puoi usare new URL(...) con espressioni arbitrarie come new URL(relativeUrl, customAbsoluteBase), tuttavia il pattern new URL('...', import.meta.url) è un indicatore chiaro per i bundler di pre-elaborare e includere una dipendenza insieme al codice JavaScript principale.

URL relativi ambigui

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

Il motivo è che, a differenza delle istruzioni di importazione, le richieste dinamiche vengono risolte relativamente 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, si potrebbe avere la tentazione di utilizzare un percorso relativo come fetch('./module.wasm').

Tuttavia, fetch non conosce l'URL del file JavaScript in cui viene eseguito; al contrario, risolve gli URL relativamente al documento. Di conseguenza, fetch('./module.wasm') tenterà di caricare http://example.com/module.wasm anziché il valore http://example.com/src/module.wasm previsto e non riuscirà (o, peggio ancora, caricherebbe in modo invisibile una risorsa diversa da quella prevista).

Se invii l'URL relativo in new URL('...', import.meta.url), puoi evitare questo problema e garantire che l'eventuale URL fornito venga risolto in relazione all'URL del modulo JavaScript corrente (import.meta.url) prima che venga trasmesso a qualsiasi caricatore.

Sostituisci fetch('./module.wasm') con fetch(new URL('./module.wasm', import.meta.url)). Verrà caricato correttamente il modulo WebAssembly previsto, oltre a offrire ai bundle un modo per trovare quei percorsi relativi anche durante la creazione.

Assistenza per strumenti

Raccoglitori

I seguenti bundle supportano già lo schema new URL:

WebAssembly

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

C/C++ tramite Emscripten

Quando usi Emscripten, puoi chiedergli di emettere colla JavaScript come modulo ES6 anziché uno script standard 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 si utilizza questa opzione, l'output userà il pattern new URL(..., import.meta.url) in background, 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 allo stesso modo e sarà rilevabile anche dai bundler e dai browser.

Ruggine tramite wasm-pack / wasm-bindgen

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

Per impostazione predefinita, emette un modulo JavaScript che si basa sulla proposta di integrazione WebAssembly ESM. Al momento della scrittura, questa proposta è ancora sperimentale e l'output funzionerà solo se associato a Webpack.

Puoi invece chiedere a wasm-pack di emettere un modulo ES6 compatibile con il browser tramite --target web:

$ wasm-pack build --target web

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

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

Nella versione breve non puoi utilizzare API thread arbitrarie, ma se usi 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(...), quindi anche i worker potranno essere individuati e inclusi dai bundler.

Funzionalità future

import.meta.resolve

Una chiamata import.meta.resolve(...) dedicata rappresenta un potenziale miglioramento futuro. Consente di risolvere gli specificatiri relativamente 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 resolver personalizzati, in quanto verrebbe integrato nello stesso sistema di risoluzione dei moduli di import. Sarebbe un segnale più forte anche per i bundler, in quanto è una sintassi statica che non dipende da API di runtime come URL.

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

Importa asserzioni

Le asserzioni di importazione sono una nuova caratteristica che consente di importare tipi diversi dai moduli ECMAScript. Per ora sono limitati a JSON:

foo.json:

{ "answer": 42 }

main.mjs:

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

Potrebbero anche essere utilizzate dai bundler per sostituire i casi d'uso attualmente coperti dal pattern new URL, ma i tipi nelle asserzioni di importazione vengono aggiunti in base al caso. Per il momento riguardano solo JSON, con moduli CSS presto disponibili, ma altri tipi di asset richiederanno comunque una soluzione più generica.

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

Conclusione

Come puoi vedere, ci sono vari modi per includere risorse non JavaScript sul web, ma hanno diversi svantaggi e non funzionano con varie toolchain. Le proposte future potrebbero permetterci di importare questi asset con una sintassi specializzata, ma non siamo ancora arrivati a questo livello.

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