Łączenie zasobów innych niż JavaScript

Dowiedz się, jak importować i łączyć różne typy zasobów z JavaScriptu.

Załóżmy, że pracujesz nad aplikacją internetową. W takim przypadku najprawdopodobniej masz do czynienia nie tylko z modułami JavaScript, ale także z innymi rodzajami zasobów – komponentami Web Workers (które należą też do JavaScriptu, ale nie są częścią zwykłego wykresu w modułach), obrazami, arkuszami stylów, czcionkami, modułami WebAssembly i innymi.

Odniesienia do niektórych z tych zasobów można umieścić bezpośrednio w kodzie HTML, ale często są one logicznie powiązane z komponentami wielokrotnego użytku. Może to być na przykład arkusz stylów niestandardowego menu powiązanego z jej częścią w języku JavaScript, obrazy ikon powiązane z komponentem paska narzędzi czy moduł WebAssembly powiązany z klejem JavaScript. W takich przypadkach wygodniej jest odwoływać się do zasobów bezpośrednio z ich modułów JavaScript i wczytywać je dynamicznie podczas wczytywania odpowiedniego komponentu.

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

Większość dużych projektów korzysta jednak z systemów kompilacji, które wykonują dodatkowe optymalizacje i reorganizację treści, na przykład grupowanie i minifikacja. Nie mogą wykonać kodu i przewidywać wyniku jego wykonania, nie mogą też przemierzyć każdego możliwego literału ciągu w JavaScript i zgadnąć, czy URL zasobu odnosi się do URL-a zasobu, czy nie. Jak mogę więc sprawić, żeby „widzieli” te zasoby dynamiczne ładowane przez komponenty JavaScript i uwzględniały je w kompilacji?

Importy niestandardowe w pakietach

Jednym z typowych sposobów jest ponowne wykorzystanie składni statycznej importu. Niektóre pakiety mogą automatycznie wykrywać format na podstawie rozszerzenia pliku, a inne pozwalają wtyczkom używać niestandardowego schematu 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 pakietu SDK znajdzie import z rozpoznawanym rozszerzeniem lub z wyraźnym schematem niestandardowym (asset-url: i js-url: w przykładzie powyżej), dodaje ten zasób do wykresu kompilacji, kopiuje go do ostatecznego miejsca docelowego, przeprowadza optymalizacje odpowiednie dla typu zasobu i zwraca końcowy adres URL, który jest używany w czasie działania.

Zalety tego podejścia: ponowne wykorzystanie składni importu JavaScriptu gwarantuje, że wszystkie adresy URL są statyczne i powiązane z bieżącym plikiem, co ułatwia systemowi kompilacji znalezienie tego typu zależności.

Ma jednak jedną poważną wadę: taki kod nie może działać bezpośrednio w przeglądarce, ponieważ przeglądarka nie wie, jak obsłużyć niestandardowe schematy importu lub rozszerzenia. Może to nie być odpowiednie, jeśli kontrolujesz cały kod i i tak polegasz na programowaniu pakietów. Jednak coraz częściej staje się coraz bardziej powszechne używanie modułów JavaScript bezpośrednio w przeglądarce, a przynajmniej podczas programowania. Pozwala to zmniejszyć liczbę problemów. Ktoś, kto pracuje nad niewielką wersją demonstracyjną, może w ogóle nie potrzebować usługi pakietu – nawet w wersji produkcyjnej.

Uniwersalny wzorzec dla przeglądarek i pakietów

Jeśli pracujesz nad komponentem wielokrotnego użytku, powinien on działać w dowolnym środowisku – bezpośrednio w przeglądarce lub wstępnie utworzony jako część większej aplikacji. Większość współczesnych pakietów aplikacji umożliwia to, akceptując ten wzorzec w modułach JavaScript:

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

Narzędzia mogą wykryć ten wzorzec statycznie, niemal tak, jakby miał on specjalną składnię. Jest to jednak prawidłowe wyrażenie JavaScript, które działa bezpośrednio w przeglądarce.

W przypadku użycia tego wzorca powyższy przykład można napisać ponownie 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? Rozłóżmy to. Konstruktor new URL(...) wykorzystuje jako pierwszy argument adres URL względny i przetwarza go w odniesieniu do bezwzględnego adresu URL podanego jako drugi argument. W naszym przypadku drugi argument to import.meta.url, który podaje adres URL bieżącego modułu JavaScript, więc pierwszy argument może być dowolną ścieżką względem niego.

Ma podobne zalety do importu dynamicznego. Chociaż można używać import(...) z dowolnymi wyrażeniami, takimi jak import(someUrl), pakiety SDK zapewniają specjalne traktowanie wzorca ze statycznym adresem URL import('./some-static-url.js'), aby wstępnie przetworzyć zależność znaną w czasie kompilowania, ale dzieląc ją na własny fragment, który jest ładowany dynamicznie.

Analogicznie możesz używać polecenia new URL(...) z dowolnymi wyrażeniami, takimi jak new URL(relativeUrl, customAbsoluteBase), jednak wzorzec new URL('...', import.meta.url) stanowi wyraźną wskazówkę dla pakietów, które muszą wstępnie przetworzyć dane i uwzględnić zależność wraz z głównym kodem JavaScript.

Niejednoznaczne względne adresy URL

Być może zastanawiasz się, dlaczego usługi tworzące pakiety nie mogą wykryć innych typowych wzorców, na przykład fetch('./module.wasm') bez kodów new URL.

Wynika to z tego, że w przeciwieństwie do instrukcji importu wszystkie żądania dynamiczne są przetwarzane względem samego dokumentu, a nie 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 wczytać plik module.wasm z platformy main.js, warto użyć ścieżki względnej, takiej jak fetch('./module.wasm').

Jednak fetch nie zna adresu URL pliku JavaScript, w którym jest wykonywany, ale rozpoznaje adresy URL względem dokumentu. W efekcie przeglądarka fetch('./module.wasm') próbowałaby załadować http://example.com/module.wasm zamiast zamierzonego http://example.com/src/module.wasm, co zakończyłoby się niepowodzeniem (lub, co gorsza, dyskretnym załadowaniem zasobu innego niż zamierzony).

Pakowanie względnego adresu URL w tag new URL('...', import.meta.url) pozwala uniknąć tego problemu i zagwarantować, że każdy z podanych adresów URL zostanie rozpoznany względem adresu URL bieżącego modułu JavaScript (import.meta.url) przed przekazaniem go do modułów ładowania.

Zastąp fetch('./module.wasm') wartością fetch(new URL('./module.wasm', import.meta.url)), aby załadować oczekiwany moduł WebAssembly, a pakietom dasz też pakietom możliwość znalezienia tych ścieżek względnych podczas kompilacji.

Pomoc dotycząca narzędzi

Pakiety

Te pakiety SDK obsługują już schemat new URL:

WebAssembly

Podczas pracy z WebAssembly zazwyczaj nie wczytuje się modułu Wasm ręcznie, a zamiast tego importuje klej JavaScript generowany przez łańcuch narzędzi. Poniższe łańcuchy narzędzi mogą wysyłać za Ciebie opisany wzór new URL(...).

C/C++ w Emscripten

Korzystając z Emscripten, możesz poprosić o emitowanie kleju JavaScript jako modułu ES6 zamiast zwykłego skryptu, korzystając z jednej 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

Gdy ta opcja jest włączona, dane wyjściowe używają wzorca new URL(..., import.meta.url), dzięki czemu osoby tworzące pakiety mogą automatycznie znaleźć powiązany plik Wasm.

Tej opcji możesz też użyć z wątkami 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 wygenerowana instancja robocza zostanie uwzględniona w taki sam sposób i będzie też możliwa do wykrycia zarówno przez programy, jak i przeglądarki.

Rdza przez wasm-pack / wam-bindgen

wasm-pack – podstawowy łańcuch narzędzi Rust dla WebAssembly – ma też kilka trybów wyjściowych.

Domyślnie emituje moduł JavaScript, który korzysta z propozycji integracji WebAssembly ESM. Obecnie ta oferta jest nadal w fazie eksperymentalnej i jej wyniki będą działać tylko po połączeniu z pakietem Webpack.

Zamiast tego możesz poprosić Wasm-pack o wyemitowanie przy użyciu --target web modułu ES6 zgodnego z przeglądarką:

$ wasm-pack build --target web

Dane wyjściowe będą korzystać z opisanego wzorca new URL(..., import.meta.url), a plik Wasm będzie też automatycznie wykrywany przez systemy tworzenia pakietów.

Jeśli w aplikacji Rust chcesz używać wątków WebAssembly, cała historia jest nieco bardziej złożona. Więcej informacji znajdziesz w odpowiedniej sekcji tego przewodnika.

W skrócie mówimy, że nie można używać interfejsów API dowolnych wątków, ale jeśli używasz Rayona, możesz połączyć go z adapterem wasm-bindgen-rayon, aby umożliwić generowanie instancji roboczych w internecie. Klej JavaScript używany przez wasm-bindgen-rayon zawiera też znajdujący się poniżej wzorca new URL(...), dzięki czemu instancje robocze będą wykrywalne i uwzględniane przez pakiety.

Przyszłe funkcje

import.meta.resolve

Specjalne połączenie z usługą import.meta.resolve(...) może być ulepszeniem w przyszłości. Umożliwiłoby to rozpoznawanie specyfikatorów względem bieżącego modułu w prostszy sposób, bez dodatkowych parametrów:

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

Lepiej integruje się też z mapami importu i niestandardowymi resolverami, ponieważ przechodzi przez ten sam system rozpoznawania modułów co import. Będzie to też silniejszy sygnał dla pakietów, ponieważ jest to składnia statyczna, która nie zależy od interfejsów API środowiska wykonawczego, takich jak URL.

Zasób import.meta.resolve został już wdrożony jako eksperyment w środowisku Node.js, ale wciąż istnieją pewne nierozwiązane pytania dotyczące jego działania w internecie.

Importowanie asercji

Potwierdzenia 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ą być też używane przez pakiety i zastąpić przypadki użycia objęte obecnie wzorcem new URL, ale typy w asercjach importu są dodawane oddzielnie dla każdego przypadku. Na razie omawiamy tylko format JSON, a moduły CSS zostaną udostępnione wkrótce, ale inne rodzaje zasobów nadal będą wymagały bardziej ogólnego rozwiązania.

Przeczytaj wyjaśnienie funkcji w wersji v8.dev, aby dowiedzieć się więcej o tej funkcji.

Podsumowanie

Jak widać, jest wiele sposobów na uwzględnienie w internecie zasobów innych niż JavaScript, ale mają one różne wady i nie sprawdzają się w różnych łańcuchach narzędzi. Kolejne propozycje mogą umożliwić nam importowanie takich zasobów o specjalnej składni, ale to jeszcze nie koniec.

Do tego czasu wzorzec new URL(..., import.meta.url) to najbardziej obiecujące rozwiązanie, które obecnie działa w przeglądarkach, różnych pakietach i łańcuchach narzędzi WebAssembly.