Бандлинг всего того, что не относится к обычному JavaScript-коду

Kate

Administrator
Команда форума
Предположим, вы работаете над веб-приложением. В таком случае весьма вероятно то, что вам приходится иметь дело не только с JavaScript-модулями, но и с самыми разными другими ресурсами. Это и веб-воркеры (их тоже пишут на JavaScript, но они обособлены от обычного кода фронтенда), и изображения, и стили, и шрифты, и WebAssembly-модули, и иные материалы, входящие в состав сайта.

Ссылки на некоторые из подобных ресурсов можно включить непосредственно в HTML-код, но часто они логически связаны с компонентами, используемыми во многих местах проектов. Например, таблица стилей для особого выпадающего списка связана с JavaScript-кодом, реализующим этот список, а изображения иконок связаны с компонентом, реализующим панель инструментов. Точно так же WebAssembly-модуль связан с JavaScript-кодом, обеспечивающим использование этого модуля. Удобнее было бы обращаться к подобным ресурсам прямо из соответствующих JavaScript-модулей и загружать их динамически тогда (или если), когда загружается соответствующий компонент.



Ресурсы разных типов, импортируемые в JS-коде

Правда, в большинстве крупных проектов используются системы для сборки таких проектов, которые выполняют дополнительные оптимизации и реорганизации контента. Например — это бандлинг и минификация ресурсов. Они не могут выполнять код и предсказывать то, каким будет результат его запуска. Они не могут и анализировать все строковые литералы в JavaScript-программах и делать предположения касательно того, является ли конкретная строка неким URL, ведущим к какому-то ресурсу, или нет. Как сделать так, чтобы бандлеры «видели» бы динамические ресурсы, загружаемые JavaScript-компонентами и включали бы их в сборку проекта?

Особые инструкции импорта в бандлерах​


Одним из распространённых подходов к работе с ресурсами, не являющимися обычным JavaScript-кодом, является применение для этой цели синтаксиса статического импорта ресурсов. Некоторые бандлеры могут автоматически определять тип данных, анализируя расширение файла, а другие поддерживают плагины, позволяющие использовать особые схемы URL, как показано в следующем примере:

// обычная команда импорта JavaScript-файла
import { loadImg } from './utils.js';

// специальные "URL-импорты" для других ресурсов
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);


Когда плагин бандлера находит команду импорта, в которой либо описывается путь к файлу с особым расширением, либо явным образом используется особая схема (asset-url: и js-url: в вышеприведённом примере), он добавляет соответствующий ресурс к графу сборки. После этого ресурсы копируются в итоговое место их хранения, выполняются оптимизации, применимые к ресурсам конкретного типа, и возвращается готовый URL, который будет использоваться во время работы кода.

Среди преимуществ такого подхода можно отметить тот факт, что использование существующих синтаксических JavaScript-конструкций для импорта ресурсов гарантирует то, что все URL будут статическими и построенными относительно текущего файла. Это упрощает загрузку подобных ресурсов системой сборки сайта.

Правда, у этого подхода есть один серьёзный недостаток: подобный код не может работать непосредственно в браузере, так как браузер не знает о том, как ему обрабатывать такие вот особые схемы импорта или расширения. Это может быть вполне приемлемо в том случае, если вы, в любом случае, контролируете весь код и полагаетесь в процессе разработки на бандлер. Но сейчас всё сильнее распространяется использование JavaScript-модулей прямо в браузере, как минимум — в процессе разработки, для того чтобы не усложнять рабочую среду. А кто-то, работающий над небольшим демонстрационным проектом, может и не нуждаться в бандлере, даже выпуская свой проект в продакшн.

Универсальный паттерн для браузеров и бандлеров​


Если вы работаете над компонентом, который рассчитан на многократное использование, это значит, что вам нужно, чтобы он работал бы в любых окружениях — и непосредственно в браузере, и в виде встроенного блока достаточно крупного приложения. Большинство современных бандлеров позволяют это, распознавая в JavaScript-модулях следующую конструкцию:

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


Этот паттерн инструменты сборки могут выявлять статически, практически так же, как если бы это была какая-то особая синтаксическая конструкция. Но при этом перед нами — корректное JavaScript-выражение, которое работоспособно и в браузере.

Если прибегнуть к этому паттерну — вышеприведённый пример можно будет переписать так:

// обычная команда импорта JavaScript-файла
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));


Давайте разберёмся с тем, как это всё работает. Вызов конструктора new URL(...) принимает, в виде первого аргумента, относительный URL и разрешает его относительно абсолютного URL, переданного ему в качестве второго аргумента. В нашем случае вторым аргументом является import.meta.url. Эта конструкция даёт нам URL текущего JavaScript-модуля, в результате первым аргументом может быть любой путь, построенный относительно этого URL.

Этот подход отличается недостатками, похожими на те, которые характерны для динамического импорта ресурсов. И, хотя можно использовать команду import(...) с передачей ей произвольного выражения вроде import(someUrl), бандлеры особым образом обрабатывают конструкции со статическими URL — вроде import('./some-static-url.js'). Это — механизм заблаговременной обработки зависимостей, известных во время компиляции. Но, тем не менее, подобные ресурсы выделяются в отдельные блоки, которые загружаются динамически.

Конструкцию new URL(...) можно, похожим образом, использовать с произвольными выражениями, вроде new URL(relativeUrl, customAbsoluteBase). При этом паттерн вида new URL('...', import.meta.url) — это чёткий сигнал для бандлеров, указывающий им на необходимость препроцессинга и сохранения зависимости там же, где хранится основной JavaScript-код.

Неоднозначные относительные URL​


Вас, возможно, интересует вопрос о том, почему бандлеры не могут выявлять другие распространённые паттерны, например — fetch('./module.wasm'), без использования обёртки new URL.

Причина этого заключается в том, что, в отличие от инструкций импорта, любые динамические запросы разрешаются относительно самого документа, а не текущего JavaScript-файла. Предположим, у нас имеется следующая структура:

index.html:

<script src="src/main.js" type="module"></script>
src/
main.js
module.wasm


Если из main.js можно загрузить module.wasm — может возникнуть соблазн использования относительного пути — наподобие fetch('./module.wasm').

Но команде fetch неизвестен URL JavaScript-файла, в котором она выполняется. Она попросту разрешает URL относительно документа. В результате fetch('./module.wasm') попытается загрузить файл http://example.com/module.wasm вместо того, который нужен (http://example.com/src/module.wasm). Эта операция окажется неудачной (или, что хуже, по-тихому будет загружен не тот ресурс, который планировалось загрузить).

Помещая относительный URL в конструкцию new URL('...', import.meta.url) можно предотвратить эту проблему и гарантировать то, что любой предоставленный системе URL будет разрешён относительно URL текущего JavaScript-модуля (import.meta.url) до того, как он будет передан какому-либо загрузчику.

Если заменить fetch('./module.wasm') на fetch(new URL('./module.wasm', import.meta.url)) — система успешно загрузит нужный WebAssembly-модуль и, кроме того, даст бандлеру механизм для нахождения подобных относительных путей во время сборки проекта.

Поддержка импорта ресурсов различными инструментами​


▍Бандлеры​


Следующие бандлеры уже поддерживают механизм new URL:


▍WebAssembly​


При использование WebAssembly-ресурсов Wasm-модули обычно вручную не загружают. Вместо этого импортируют вспомогательные JavaScript-файлы, генерируемые используемыми при работе над сайтом наборами инструментов. Те наборы инструментов, о которых пойдёт речь ниже, могут автоматически генерировать конструкции вида new URL(...).

▍C/C++ через Emscripten​


При использовании Emscripten можно запросить выдачу вспомогательных JavaScript-файлов в виде ES6-модулей вместо обычного JS-кода, воспользовавшись одной из следующих опций:

$ emcc input.cpp -o output.mjs
## или, если не нужно использовать расширение .mjs
$ emcc input.cpp -o output.js -s EXPORT_ES6


При использовании этой опции в итоге автоматически будет использован паттерн new URL(..., import.meta.url), в результате бандлеры смогут сами обнаруживать необходимые Wasm-файлы.

Этот вариант можно использовать и с потоками WebAssembly, применив флаг -pthread:

$ emcc input.cpp -o output.mjs -pthread
## или, если не нужно использовать расширение .mjs
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread


В данном случае сгенерированный веб-воркер будет включён в проект с использованием того же подхода, его смогут обнаружить и бандлеры, и браузеры.

▍Rust через wasm-pack / wasm-bindgen​


Wasm-pack — это основной набор инструментов, предназначенный для тех, кто, для создания WebAssembly-файлов, пользуется Rust. Он тоже поддерживает несколько режимов вывода данных.

По умолчанию этот набор инструментов выдаёт JavaScript-модули, соответствующие предложению esm-integration. На момент написания этого текста данная технология всё ещё находится в разряде экспериментальных, а то, что выдаст wasm-pack, будет работоспособно лишь при применении для сборки проекта Webpack.

Вместо этого можно указать wasm-pack на то, что он должен выдать ES6-модуль, совместимый с браузером, воспользовавшись опцией --target web:

$ wasm-pack build --target web


При таком подходе в выходных материалах будет использована вышеописанная конструкция new URL(..., import.meta.url), при этом Wasm-файл, как и прежде, может быть автоматически обнаружен бандлерами.

Если вам нужно пользоваться потоками WebAssembly, применяя Rust, то ситуация несколько усложняется. Для того чтобы почитать подробности об этом — загляните сюда.

Если описать это в двух словах, то окажется, что тут нельзя применять произвольные API для работы с потоками, но при использовании Rayon можно воспользоваться и адаптером wasm-bindgen-rayon, что позволит создавать воркеры в веб-среде. Во вспомогательных JavaScript-файлах, используемых wasm-bindgen-rayon, тоже поддерживается паттерн new URL(...), в результате воркеры смогут обнаруживать и включать в сборки и бандлеры.

Будущие возможности​


▍import.meta.resolve​


Специальный вызов import.meta.resolve(...) выглядит как возможное улучшение вышеописанных механизмов импорта ресурсов. Он позволит разрешать спецификаторы относительно текущего модуля, делая это проще, без необходимости указания дополнительных параметров:

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


Этот механизм, кроме того, лучше интегрируется с картами импорта и с особыми системами разрешения адресов, так как работает он посредством той же системы поиска модулей, что и import. Обнаружение в коде подобной конструкции будет представлять собой и более чёткий сигнал для бандлеров, так как она представлена статическим синтаксисом, не зависящим от API, работающих во время выполнения кода, вроде URL.

Уже существует экспериментальная реализация import.meta.resolve в Node.js, но имеются ещё некоторые нерешённые вопросы, касающиеся того, как этот механизм должен работать в веб-среде.

▍Утверждения импорта​


Утверждения импорта — это новая возможность, которая позволяет импортировать типы, отличные от ECMAScript-модулей. Их использование пока ограничено импортом JSON-ресурсов:

foo.json:

{ "answer": 42 }


main.mjs:

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


Они, кроме того, могут использоваться бандлерами и способны заменить паттерн new URL в ситуациях, где применяется этот паттерн. Но типы в утверждениях импорта добавляются с учётом каждого конкретного сценария их использования. Так, сейчас поддерживается лишь JSON, скоро должна появиться поддержка CSS-модулей, а вот для работы с ресурсами других видов всё ещё нужно более универсальное решение. Подробности об этом можно почитать здесь.

Итоги​


Как видите, существуют различные способы включения в состав веб-проектов ресурсов, не являющихся обычным JavaScript-кодом. Эти способы имеют различные недостатки, они не подходят для использования в абсолютно всех наборах инструментов веб-разработки. В будущем, возможно, появятся механизмы, которые позволят импортировать подобные ресурсы с использованием особых синтаксических конструкций, но мы ещё до подобных вещей не добрались.

Пока же паттерн new URL(..., import.meta.url) — это наиболее перспективное решение, которое уже работает в браузерах, в различных бандлерах и в наборах инструментов, ориентированных на WebAssembly.

 
Сверху