ESM. Выходим за рамки
Итак, работая над… ну не знаю… каким-нибудь замечательным генератором статики, вы, возможно, захотите импортировать в свой код зависимости напрямую из текстовых файлов, таких как: HTML, MD, CSS, SVG или JSON. Конечно, можно использовать бандлер с соответствующим лоадером. Но, допустим, ваш кодекс самурая велит вам — никаких лишних npm install xxx
и промежуточных билдов! Только хардкор! Что делать? Выход есть.
Сперва, как обычно, ссылочка на описание спеки, чтобы отмести лишние недоразумения и недопонимания.
И еще пара формальностей перед стартом:
Убедитесь, что у вас установлена свежая нода, а некро-нода — надежно закопана.
Если хотите писать изоморфный код, не используйте расширения
*.mjs
, браузеры такого не любят (не забудьте в настройкахpackage.json
врубить"type": "module"
).
Упражнение первое. Нода. Кастомный лоадер (хук).
Не забыли ноду обновить? А то может не сработать.
Делай раз (loader.js):
import { URL } from 'url';
import { readFile } from 'fs/promises';
function checkUrl(url) {
return !!['.html', '.htm', '.md', '.css', '.svg', '.json'].find((res) => {
return url.endsWith(res);
});
}
export async function load(url, context, defaultLoad) {
if (checkUrl(url)) {
const content = (await readFile(new URL(url))).toString();
return {
format: 'module',
source: `export default ${url.endsWith('json') ? content : JSON.stringify(content)};`,
shortCircuit: true,
};
}
return defaultLoad(url, context, defaultLoad);
}
Делай два (my-app.js):
import html from './index.html';
import doc from './doc.md';
import css from './styles.css';
import svg from './image.svg';
import data from './data.json';
console.log(html, doc, css, svg, data);
Делай три:
node --loader ./loader.js ./my-app.js
Ссылка на доку: https://nodejs.org/api/esm.html#loaders
В документации технология помечена как экспериментальная, однако, в свежей версии, параметр
--experimental-loader
заменили на--loader
, что, косвенно, но говорит нам о том, что технология на подходе к стабильному состоянию. В любом случае, неплохо знать о такой возможности.
Упражнение второе. Браузер. Перехват запросов.
Переносимся в браузерный рантайм. Задача та-же: хотим грузить модули, которые вообще никакие не модули. Никаких специальных хуков для ESM браузеры не поддерживают. Что делать? Идем на перехват с помощью Service Worker.
Технологии иные, но принцип сохранился. Создаем файл сервис-воркера (sw.js):
function checkUrl(url) {
return !!['.html', '.htm', '.md', '.css', '.svg', '.json'].find((res) => {
return url.endsWith(res);
});
}
async function handleRequest(req) {
let content = await (await self.fetch(req.url)).text();
let resp = new Response(`export default ${req.url.endsWith('json') ? content : JSON.stringify(content)};`, {
headers: { 'Content-Type': 'text/javascript' }
});
return resp;
}
self.addEventListener('fetch', (e) => {
if (e.request.destination === 'script' && checkUrl(e.request.url)) {
e.respondWith(handleRequest(e.request));
}
});
Регистрируем воркер на странице:
navigator.serviceWorker.register('./sw.js');
Вуаля — используем кастомные импорты:
import html from './index.html';
import doc from './doc.md';
import css from './styles.css';
import svg from './image.svg';
import data from './data.json';
console.log(html, doc, css, svg, data);
Доки по воркерам прочитайте внимательно, там есть ряд важных нюансов, с ними лучше ознакомиться перед использованием подобных штук в проде. Но сейчас мы получили от браузера все, что хотели.
Упражнение третье. Нода + браузер. Параметры запроса.
Теперь будет больше, кхм, наркомании. Я просто покажу тропинку, а вы сами решите, ходить по ней или нет. Итак, возможно именно Вы об этом знали, но для тех, кто не знал, я сообщу: в ESM поддерживаются такие штуки как top level await
. Это значит, что перед тем, как модуль отдаст свой экспорт, вы можете совершить в нем что-то асинхронное. Например, сделать запрос и получить на него ответ. Что нам это дает? Правильно:
Делаем модуль-загрузчик (load.js):
let result = null;
const path = import.meta.url.split('#')[1];
if (path) {
let content;
if (typeof window === 'object') {
content = await (await fetch(path)).text();
} else {
const fs = (await import('fs')).default;
content = fs.readFileSync(path).toString();
}
result = path.includes('.json') ? JSON.parse(content) : content;
}
export default result;
Грузим свои ассеты через прокси-модуль:
import html from './load.js?#./index.html';
import doc from './load.js?#./doc.md';
import css from './load.js?#./styles.css';
import svg from './load.js?#./image.svg';
import data from './load.js?#./data.json';
console.log(html, doc, css, svg, data);
Это хотя и очень упрощенный пример, но уже изоморфный. Не думаю, что кто-то всерьез станет использовать такое для загрузки модулей, но сам принцип, на мой взгляд, как минимум, интересен и стимулирует инженерную фантазию.
Важная деталь: экспорты каждого модуля кэшируются. И этот кэш привязан к адресу модуля. Поэтому, если ваши импортируемые файлы меняются динамически — пути к модулям тоже должны меняться. А это значит, что импорты получится использовать только динамические. Помня про поддержку top level await
, мы не будем считать это большой проблемой.
Итого.
Вы можете спросить меня: -, а есть ли в этом вообще какая-то практическая ценность? Ведь можно просто использовать те-же fs.readFileSync
или fetch
… Я, пожалуй, уклонюсь от прямого ответа и напомню, что если звезды зажигают — значит это кому-нибудь нужно.
Надеюсь было весело. Еще больше надеюсь, что вы узнали для себя что-то полезное. Используйте больше ESM и меньше CJS. Всем — добра и мира.