Предположим, вы работаете над веб-приложением. В таком случае весьма вероятно то, что вам приходится иметь дело не только с 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-код.
Вас, возможно, интересует вопрос о том, почему бандлеры не могут выявлять другие распространённые паттерны, например — 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-ресурсов Wasm-модули обычно вручную не загружают. Вместо этого импортируют вспомогательные JavaScript-файлы, генерируемые используемыми при работе над сайтом наборами инструментов. Те наборы инструментов, о которых пойдёт речь ниже, могут автоматически генерировать конструкции вида new URL(...).
При использовании 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
В данном случае сгенерированный веб-воркер будет включён в проект с использованием того же подхода, его смогут обнаружить и бандлеры, и браузеры.
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(...) выглядит как возможное улучшение вышеописанных механизмов импорта ресурсов. Он позволит разрешать спецификаторы относительно текущего модуля, делая это проще, без необходимости указания дополнительных параметров:
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.
Ссылки на некоторые из подобных ресурсов можно включить непосредственно в 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:
- Webpack v5
- Rollup (Это возможно с использованием плагина @web/rollup-plugin-import-meta-assets для обычных ресурсов и плагина @surma/rollup-plugin-off-main-thread исключительно для воркеров.)
- Parcel v2 (бета-версия)
- Vite
▍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.
Бандлинг всего того, что не относится к обычному JavaScript-коду
Предположим, вы работаете над веб-приложением. В таком случае весьма вероятно то, что вам приходится иметь дело не только с JavaScript-модулями, но и с самыми разными другими ресурсами. Это и...
habr.com