Łączenie zasobów innych niż JavaScript

Dowiedz się, jak importować i zbiorczo pakować różne typy komponentów z JavaScriptu.

Załóżmy, że pracujesz nad aplikacją internetową. W tym przypadku prawdopodobnie będziesz mieć do czynienia nie tylko z modułami JavaScript, ale też z różnymi innymi zasobami – skryptami web worker (które są też pisane w JavaScript, ale nie są częścią zwykłego grafu modułów), obrazami, arkuszami stylów, czcionkami, modułami WebAssembly i innymi.

Można dołączać odwołania do niektórych z tych zasobów bezpośrednio w pliku HTML, ale często są one logicznie połączone z komponentami wielokrotnego użytku. Na przykład arkusz stylów dla niestandardowego menu rozwijanego powiązanego z jego częścią JavaScriptu, obrazy ikon powiązane ze składnikiem paska narzędzi lub moduł WebAssembly powiązany z elementem JavaScript. W takich przypadkach wygodniej jest odwoływać się do zasobów bezpośrednio z ich modułów JavaScript i ładować je dynamicznie podczas wczytywania odpowiedniego komponentu.

Wykres przedstawiający różne typy zasobów zaimportowanych do JS.

Jednak większość dużych projektów ma systemy kompilacji, które wykonują dodatkowe optymalizacje i reorganizację treści, na przykład przez zwijanie i zwijanie. Nie mogą wykonać kodu ani przewidzieć wyniku jego wykonania. Nie mogą też przejrzeć wszystkich możliwych ciągów znaków w JavaScript i zgadnąć, czy jest to adres URL zasobu. Jak więc sprawić, aby „widziały” zasoby dynamiczne wczytane przez komponenty JavaScript i włączały je w kompilację?

Importy niestandardowe w programach tworzenia pakietów

Jednym z typowych sposobów jest ponowne użycie składni importu stałego. W niektórych programach do tworzenia pakietów format może być automatycznie wykrywany na podstawie rozszerzenia pliku, a w innych programach wtyczki mogą używać niestandardowego schematu adresu URL, jak w tym przykładzie:

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

Gdy wtyczka do tworzenia pakietów znajdzie import z rozszerzeniem, które rozpoznaje, lub z wyraźnym schematem niestandardowym (asset-url:js-url: w przykładzie powyżej), dodaje do grafu kompilacji zasób, do którego się odwołuje, kopiuje go do docelowego miejsca, wykonuje optymalizacje odpowiednie dla typu zasobu i zwraca końcowy adres URL, który ma być używany w czasie wykonywania.

Zalety tego podejścia: ponowne używanie składni importu JavaScriptu gwarantuje, że wszystkie adresy URL są statyczne i względne względem bieżącego pliku, co ułatwia systemowi kompilacji znajdowanie takich zależności.

Ma on jednak jedną istotną wadę: nie działa bezpośrednio w przeglądarce, ponieważ przeglądarka nie wie, jak obsługiwać te niestandardowe schematy importowania ani rozszerzenia. Może to być odpowiednie, jeśli masz kontrolę nad całym kodem i i tak używasz pakietu do tworzenia aplikacji, ale coraz częściej, aby zmniejszyć tarcie, moduły JavaScript są używane bezpośrednio w przeglądarce, przynajmniej podczas tworzenia aplikacji. Ktoś, kto pracuje nad małą wersją demonstracyjną, może w ogóle nie potrzebować narzędzia do tworzenia pakietów, nawet w wersji produkcyjnej.

Uniwersalny schemat dla przeglądarek i bundlerów

Jeśli pracujesz nad komponentem wielokrotnego użytku, chcesz, aby działał w dowolnym środowisku, niezależnie od tego, czy jest używany bezpośrednio w przeglądarce, czy jest wstępnie utworzony jako część większej aplikacji. Większość współczesnych pakietów pozwala na to, akceptując ten wzór w modułach JavaScript:

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

Ten wzorzec może być wykrywany statycznie przez narzędzia, prawie tak jakby był specjalną składnią, ale jest to prawidłowy wyrażenie JavaScript, które działa bezpośrednio w przeglądarce.

W przypadku tego wzorca powyższy przykład można zapisać w takiej postaci:

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

Jak to działa? Przejdźmy do rzeczy. Konstruktor new URL(...) przyjmuje jako pierwszy argument adres URL względny i rozwiązuje go na podstawie adresu URL bezwzględnego podanego jako drugi argument. W naszym przypadku drugim argumentem jest import.meta.url, który podaje adres URL bieżącego modułu JavaScript, więc pierwszym argumentem może być dowolna ścieżka względna.

Ma podobne wady i zalety jak import dynamiczny. Chociaż można używać import(...) z dowolnymi wyrażeniami, takimi jak import(someUrl), narzędzia do tworzenia pakietów obsługują w szczególny sposób wzór z statycznym adresem URL import('./some-static-url.js'), aby wstępnie przetwarzać zależność znaną w czasie kompilacji, ale dzielić ją na własny kawałek, który jest ładowany dynamicznie.

Podobnie możesz użyć new URL(...) z dowolnymi wyrażeniami, np. new URL(relativeUrl, customAbsoluteBase), ale wzór new URL('...', import.meta.url) jest wyraźnym sygnałem dla twórców pakietów, aby przetworzyli i umieścili zależność obok głównego kodu JavaScript.

niejednoznaczne względne adresy URL,

Możesz się zastanawiać, dlaczego pakietatory nie wykrywają innych typowych wzorców, np. fetch('./module.wasm') bez obudowy new URL.

Dzieje się tak, ponieważ w przeciwieństwie do instrukcji importu wszelkie żądania dynamiczne są rozwiązywane w stosunku do samego dokumentu, a nie do bieżącego pliku JavaScript. Załóżmy, że masz taką strukturę:

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

Jeśli chcesz załadować module.wasmmain.js, możesz użyć ścieżki względnej, np. fetch('./module.wasm').

Funkcja fetch nie zna jednak adresu URL pliku JavaScript, w którym jest wykonywana. Zamiast tego rozwiązuje adresy URL w stosunku do dokumentu. W rezultacie funkcja fetch('./module.wasm') spróbuje wczytać element http://example.com/module.wasm zamiast http://example.com/src/module.wasm i nie uda jej się to (lub co gorsza, wczyta inny zasób niż ten, który był potrzebny).

Owijając adres URL bezwzględny w funkcji new URL('...', import.meta.url), możesz uniknąć tego problemu i zapewnić, że każdy podany adres URL zostanie rozwiązany w stosunku do adresu URL bieżącego modułu JavaScript (import.meta.url), zanim zostanie przekazany ładownikom.

Zastąp fetch('./module.wasm') wartością fetch(new URL('./module.wasm', import.meta.url)), aby załadować oczekiwany moduł WebAssembly. Dzięki temu pakietujący będą mogli też znaleźć te ścieżki względne w czasie kompilacji.

Pomoc dotycząca narzędzi

Bundlers

Schemat new URL jest już obsługiwany przez te usługi:

WebAssembly

Podczas pracy z WebAssembly zwykle nie ładujesz ręcznie modułu Wasm, ale importujesz zamiast tego kod pośredniczący JavaScript generowany przez zestaw narzędzi. Poniższe łańcuchy narzędzi mogą generować opisany wzór new URL(...).

C/C++ za pomocą Emscripten

Używając Emscripten, możesz poprosić o wygenerowanie kodu JavaScript jako modułu ES6 zamiast zwykłego skryptu, korzystając z jednego z tych opcji:

$ 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

W przypadku użycia tej opcji dane wyjściowe będą używać w tabeli new URL(..., import.meta.url), aby umożliwić pakietownikom automatyczne znajdowanie powiązanego pliku Wasm.

Możesz też użyć tej opcji w wątkach WebAssembly, dodając 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

W takim przypadku wygenerowany Web Worker zostanie uwzględniony w taki sam sposób i będzie widoczny zarówno dla pakietów, jak i przeglądarek.

Rust za pomocą wasm-pack / wasm-bindgen

Wasm-pack, czyli główny łańcuch narzędzi Rusta do WebAssembly, ma też kilka trybów wyjścia.

Domyślnie wygeneruje moduł JavaScript, który korzysta z propozycja integracji ESM WebAssembly. W momencie pisania tego artykułu ta propozycja jest nadal eksperymentalna, a wyniki będą działać tylko w połączeniu z Webpack.

Zamiast tego możesz poprosić wasm-pack o wygenerowanie kompatybilnego z przeglądarką modułu ES6 za pomocą --target web:

$ wasm-pack build --target web

Dane wyjściowe będą używać opisanego wzoru new URL(..., import.meta.url), a plik Wasm będzie automatycznie wykrywany przez pakietatory.

Jeśli chcesz używać wątków WebAssembly z Rust, sprawa jest nieco bardziej skomplikowana. Aby dowiedzieć się więcej, zapoznaj się z odpowiednią sekcją przewodnika.

Krótko mówiąc, nie możesz używać dowolnych interfejsów wątków, ale jeśli używasz Rayon, możesz połączyć go z adapterem wasm-bindgen-rayon, aby mógł tworzyć pracowników w sieci. Elementy spajające JavaScript używane przez wasm-bindgen-rayon zawierają pod maską wzór new URL(...), dzięki czemu będą one możliwe do znalezienia i uwzględnione przez narzędzia do tworzenia pakietów.

Przyszłe funkcje

import.meta.resolve

W przyszłości możemy wprowadzić import.meta.resolve(...). Umożliwi to rozwiązywanie wskaźników w stosunku do bieżącego modułu w bardziej bezpośredni sposób, bez dodatkowych parametrów:

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

Lepiej też integrowałby się z mapami importu i niestandardowymi rozwiązalnikami, ponieważ korzystałby z tego samego systemu rozwiązywania modułów co import. Byłoby to też silniejsze sygnalizowanie dla dostawców pakietów, ponieważ jest to składnia statyczna, która nie zależy od interfejsów API w czasie wykonywania, takich jak URL.

import.meta.resolve jest już zaimplementowany jako eksperyment w Node.js, ale wciąż pozostaje kilka nierozwiązanych kwestii dotyczących jego działania w internecie.

Zasady importowania

Założenia importu to nowa funkcja, która umożliwia importowanie typów innych niż moduły ECMAScript. Obecnie są one ograniczone do formatu JSON:

foo.json:

{ "answer": 42 }

main.mjs:

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

Mogą one być też używane przez dostawców pakietów i zastępować przypadki użycia obecnie obsługiwane przez wzór new URL, ale typy w oświadczeniach importu są dodawane w odpowiednich przypadkach. Obecnie obejmują one tylko JSON, a wkrótce pojawią się moduły CSS, ale inne rodzaje zasobów nadal będą wymagać bardziej ogólnego rozwiązania.

Aby dowiedzieć się więcej o tej funkcji, zapoznaj się z opisem funkcji na stronie v8.dev.

Podsumowanie

Jak widzisz, istnieją różne sposoby włączania zasobów innych niż JavaScript do witryny, ale mają one różne wady i nie działają w różnych zestawach narzędzi. W przyszłości być może będziemy mogli importować takie zasoby za pomocą specjalnej składni, ale na razie jeszcze tego nie robimy.

Do tego czasu wzór new URL(..., import.meta.url) jest najbardziej obiecującym rozwiązaniem, które działa już w przeglądarkach, różnych programach do tworzenia pakietów i narzędzi WebAssembly.