Идея вот в чем: берем стандартные JavaScript модули (ESM) и делаем их прямыми эндпоинтами для генерации любых текстовых веб-ассетов, таких как HTML-файлы, CSS, SVG или даже JSON или Markdown, используя простое соглашение о именовании исходных файлов и дефолтный экспорт результата в виде строки (JavaScript Template Literal). Проще некуда и чем-то похоже на PHP, верно? Но, что это нам дает?
Давайте разберемся, почему JSDA (JavaScript Distributed Assets) - это то, что может сделать веб-разработку "грейт эгейн", после тысячи поворотов "не туда".
Базовый пример
// Создаем файл index.html.js
// Импортируем метод для доступа к данным (опционально):
import { getPageData } from './project-data.js';
// Получаем необходимые данные (опционально):
const pageData = await getPageData();
// Экспортируем итоговый HTML-документ:
export default /*html*/ `
<html>
<head>
<title>${pageData.title}</title>
</head>
<body>
<h1>${pageData.heading}</h1>
</body>
</html>
`;
Это будет автоматически преобразовано в обычный HTML:
<html>
<head>
<title>Page Title</title>
</head>
<body>
<h1>Page Heading</h1>
</body>
</html>
Соглашения
JSDA - подразумевает следование следующим соглашениям:
Для определения типа выходного файла, используется шаблон
*.<type>.js, напримерmy-page.html.js,styles.css.js,image.svg.jsи т.д.Для определения входной точки генерации статики, используется шаблон
index.<type>.js, напримерindex.html.js,index.css.js,index.svg.jsи т.д.JSDA-файл должен быть стандартным ESM-модулем, содержащим экспорт в формате строки по умолчанию (
export default '...')Структура каталогов при выводе результирующих файлов для статики отражает структуру исходников (мы получаем роутинг на уровне файловой системы):
src/
├── index.html.js → dist/index.html
├── styles/
│ └── index.css.js → dist/styles/index.css
└── assets/
└── logo/
└── index.svg.js → dist/assets/logo/index.svg
JSDA как эволюция JAMStack
Идти по пути упрощения - сложно. Когда мы выкидываем лишнее - нам кажется, что мы можем упустить что-то важное. Но, на практике, часто бывает наоборот - лишние элементы системы делают ее менее устойчивой и более уязвимой. Упрощать - это искусство, которое в ретроспективе может показаться банальным. Но это далеко не так.
Когда CEO Netlify, Мэтт Бильманн в 2015 году предложил архитектурную концепцию JAMStack, он сделал именно это - упростил. Действительно, зачем нам CMS и база данных, со всеми их уязвимостями и потреблением ресурсов на сервере, если он (сервер), в результате должен отдавать просто статику? Зачем серверу сложная логика, если можно генерировать необходимые ассеты на этапе сборки? Почему-бы не раздавать все статические файлы максимально быстро, эффективно и безопасно через CDN, минимизируя нагрузку и существенно улучшая масштабируемость при этом? И главное, зачем усложнять, если лучшего результата мы можем достигнуть упростив? Довольно контринтуитивный ход мыслей на тот момент, но очень правильный.
Однако, по моему мнению, JAMStack - это слишком общая концепция, набор самых верхнеуровневых рекомендаций, практически, не касающихся никаких деталей реализации и не предлагающих решения конкретных задач, которые могут обладать собственной сложностью. Многие продолжают считать, что у JAMStack подхода есть существенные ограничения, хотя это не так, ведь мы можем комбинировать все это с любыми другими практиками в любых произвольных сочетаниях.
В JSDA - та-же философия, но больше технических деталей. Мы берем нативные возможности веб-платформы (Node.js + браузер) и существующие стандарты, и, не добавляя никаких новых избыточных сущностей, решаем задачи, для которых, традиционно, используются значительно более громоздкие решения, при этом, никак себя не ограничивая в диапазоне возможностей.
Гибридный подход
Если JAMStack - это, преимущественно, про SSG (генераторы статических сайтов), то JSDA - играет на обоих полях, как архитектура применимая для генерации статики, так и динамики. Помимо этого, JSDA никак не ограничивает вас в использовании любых вспомогательных технологий, если это потребуется.
Традиционно, мы выделяем следующие варианты работы веб-приложений:
SPA (Single Page Application) - все под контролем клиентского JavaScript, DOM-дерево создается полностью динамически на клиенте.
SSR (Server Side Rendering) - сервер предварительно рендерит страницу, которая, впоследствии, "оживляется" на клиенте с помощью JavaScript.
SSG (Static Site Generation) - создаем необходимые HTML-файлы на этапе сборки, которые затем раздаются как статика.
Динамическая генерация страниц - сервер генерирует HTML-файлы на этапе запроса, результат можно кэшировать.
Эти подходы не исключают друг друга. У каждого из них есть свои сильные и слабые стороны, но их можно эффективно сочетать. В сложных сценариях, например, страницы документации или промо-материалы могут быть полностью статическими, страницы с товарами - могут быть, частично, предварительно созданы на сервере а частично - содержать динамические виджеты (корзина), а личный кабинет пользователя - может быть полностью реализован как SPA.
И JSDA-стек - как раз применим для таких сложных сценариев, оставаясь простым и минималистичным сам по себе.
Асинхронные операции (Top level await)
В соответствии со спецификациями, ESM модули асинхронны и поддерживают асинхронные вызовы верхнего уровня - Top level await. Это значит, что при генерации ассетов, мы можем делать запросы и получать данные по всей цепочке импортов и зависимостей. Мы можем обращаться к базе данных, внешним API, файловой системе и т.д. Причем, нам не нужно заботится о асинхронной сложности самостоятельно, она "маскируется" под стандартными механизмами ESM, и делая простой импорт зависимости, мы можем быть уверены, что все ее данные получены на этапе резолюции модуля. На мой взгляд, это очень мощная, но недооцененная возможность платформы.
Кэширование
Согласно стандарту, ESM-модули автоматически кэшируются в памяти рантайма в момент их резолюции. Это позволяет делать процесс генерации более эффективным, не совершая никаких лишних операций при повторном использовании модуля. Если же, напротив, мы хотим контролировать кэширование и получать актуальные данные из цепочки импортов, мы можем использовать уникальные идентификаторы (адреса) модулей при импорте, например так:
const data = (await import(`./data.js?${Date.now()}`)).default;
При этом, мы можем использовать параметры запроса, которые будут доступны внутри модуля через import.meta, например:
const userId = import.meta.url.split('?')[1];
const data = (await import(`./user-data.js?id=${userId}`)).default;
Интерполяция строк
Еще одна интересная штука, доставшаяся нам "бесплатно" - это возможность сложной композиции результирующей строки. Этот нативный "шаблонизатор" позволяет формировать любую сложную разметку или иную структуру из компонентов, повторно использовать их и внедрять любую логику при генерации. На фоне этой возможности, модные серверные компоненты из экосистемы React и Next.js - выглядят просто переусложненной глупостью.
SSR и Веб-компоненты
Но как нам оживлять разметку на клиенте? Как связать DOM-элементы с данными и обработчиками? Для этого у нас также есть все необходимое, нам ничего не нужно дополнительно изобретать. Решение - стандартная группа спецификаций, известная как веб-компоненты. Приведу упрощенный пример того, как это работает.
На сервере создаем следующую разметку:
import getUserData from './getUserData.js';
let userId = import.meta.url.split('?user-id=')[1];
const userData = await getUserData(userId);
export default /*html*/ `
<my-component user-id="${userId}">
<div id="name">${userData.name}</div>
<div id="age">${userData.age}</div>
</my-component>
`;
На клиенте регистрируем CustomElement:
// функция getUserData может быть изоморфной
import getUserData from './getUserData.js';
class MyComponent extends HTMLElement {
connectedCallback() {
this.userNameEl = this.querySelector('#name');
this.userAgeEl = this.querySelector('#age');
this.userId = this.getAttribute('user-id');
// Уточняем и привязываем данные, если необходимо
getUserData(this.userId).then((data) => {
this.userNameEl.textContent = data.name;
this.userAgeEl.textContent = data.age;
});
}
}
window.customElements.define('my-component', MyComponent);
Вот и все, и никакой ужасной __NEXT_DATA__ как в Next.js. Идентификатором "оживляемой" ноды является наш кастомный тег, который легко перехватывает контроль за своим участком DOM. За все отвечает сам браузер и жизненный цикл CustomElements. Помните, что веб-компоненты вовсе не обязательно должны содержать Shadow DOM.
Но что делать, если компонентов много и у них есть собственная иерархия вложенности? Это тоже просто, привожу очередной пример.
На этот раз, простая рекурсивная функция нарисует нам структурированный HTML:
import fs from 'fs';
/**
*
* @param {String} html исходный HTML
* @param {String} tplPathSchema схема пути к шаблонам компонентов (например, './ref/wc/{tag-name}/tpl.html')
* @param {Object<string, string>} [data] данные для рендеринга
* @returns {Promise<String>} рендеринг HTML
*/
export async function wcSsr(html, tplPathSchema, data = {}) {
Object.keys(data).forEach((key) => {
html = html.replaceAll(`{[${key}]}`, data[key]);
});
const matches = html.matchAll(/<([a-z]+-[\w-]+)(?:\s+[^>]*)?>/g);
for (const match of matches) {
const [fullMatch, tagName] = match;
let tplPath = tplPathSchema.replace('{tag-name}', tagName);
let tpl = '';
// Поддерживаем шаблоны компонентов в разных форматах:
if (tplPath.endsWith('.html')) {
tpl = fs.existsSync(tplPath) ? fs.readFileSync(tplPath, 'utf8') : '';
} else if (tplPath.endsWith('.js') || tplPath.endsWith('.mjs')) {
try {
tpl = (await import(tplPath)).default;
} catch (e) {
console.warn('Template not found for ', tagName);
}
}
if (tpl) {
if (!tpl.includes(`<${tagName}`)) {
tpl = await wcSsr(tpl, tplPathSchema, data);
} else {
tpl = '';
console.warn(`Endless loop detected for component ${tagName}`);
}
}
html = html.replace(fullMatch, fullMatch + tpl);
}
return html;
}
Для более полноценной работы с веб-компонентами вы можете использовать любую популярную библиотеку. Лично я использую Symbiote.js, так как он адаптирован для работы с независимыми от контекста исполнения HTML-шаблонами (и не только).
Преобразования файлов
С преобразованием JSDA-файлов в текстовые веб-ассеты все понятно, но возникает следующий вопрос, как нам представить обычные текстовые файлы в формате JSDA?
Очень просто:
import fs from 'fs';
export default fs.readFileSync('./index.html', 'utf8');
Перед экспортом содержания файла, можно проводить любые промежуточные преобразования, например, можно добавлять нужную цветовую палитру в SVG и даже делать автоматический перевод документа через обращение к LLM.
CDN, HTTPS-импорты и npm
Вернемся к стандарту ESM и его замечательным возможностям, а именно к возможности загрузки модулей напрямую из CDN через HTTPS. Это полноценно поддерживается как в Node.js, так и в браузере. На уровне CDN (или даже вашего собственного ендпоинта) может происходить автоматическая промежуточная сборка и минификация модулей. Так работает множество популярных специализированных CDN для доставки кода, таких как jsDelivr, esm.run, esm.sh, cdnjs и многие другие. Это позволяет эффективно управлять внешними зависимостями, разделять циклы деплоймента для элементов сложных систем.
Для управления такими зависимостями, крайне полезным инструментом является нативная браузерная технология importmap. Забудьте о всяких нелепых штуках типа Module Federation, платформа уже предоставляет нам все необходимое.
Также, для работы с JSDA-зависимостями (особенно в контексте сервера) отлично подходит простой советский npm, где из коробки мы получаем контроль версий.
Распределенная композиция
Все вышеперечисленное в предыдущем разделе объясняет наличие слова "Distributed" в акрониме JSDA. Простые и понятные модели композиции очень важны на экосистемном уровне, где поставщики решений могут быть слабо связаны между собой.
В экосистеме JSDA - очень просто создавать интегрируемые решения, так как вы всегда можете опираться на самые базовые стандарты и спецификации, не изобретая лишние велосипеды.
Безопасность
Один из ключевых механизмов безопасности в JSDA-стеке - это SRI (Subresource Integrity) - проверка целостности JSDA-зависимостей через хеширование.
Изоморфизм
В самом начале, я упомянул PHP, и у вас может возникнуть вопрос: если JSDA работает почти как PHP, то почему бы просто не использовать PHP?
Во первых, PHP - это процессор гипертекста. Работа с выходными форматами отличными от HTML (XML) там может быть не такой безболезненной, как хотелось бы. В PHP просто нет полноценного аналога шаблонных литералов, как в JS.
Во вторых, JavaScript - это, нравится вам это или нет, единственный язык программирования в вебе, полноценно и нативно поддерживающийся как на сервере, так и на клиенте.
В третьих, и это самое главное, используя один язык вы можете повторно использовать одни и те-же сущности в серверном и клиентском коде, писать, так называемый, "изоморфный" код, за счет чего ЗНАЧИТЕЛЬНО экономить на всех сопутствующих разработке процессах, включая ментальные, в вашей голове. Вам не приходится переключаться между языками, не приходится писать кучу отдельных конфигов, ваш проект проще тестировать и поддерживать c меньшими ресурсами.
Благодаря изоморфизму и единому языку, JSDA, будучи, преимущественно, серверной технологией, при необходимости, может легко, и, практически бесшовно, применяться и на клиенте.
TypeScript
Без TS в современной разработке - никуда. Однако, сам TypeScript, будучи важным экосистемным инструментом статического анализа, содержит в себе ряд сложностей и спорных моментов, которые всплывают как только вы попытаетесь сделать нечто более заковыристое. Если вы когда-либо занимались разработкой собственных библиотек - вы сразу поймете о чем я говорю.
Автор этих строк пришел к использованию деклараций типов напрямую в JS-коде в формате JSDoc, совместно с дополнительными файлами *.d.ts, как к наиболее сбалансированной практике использования TypeScript. И в этом я не одинок, я встречаю все больше опытных разработчиков, которые делают так-же.
Применимо к JSDA, такой подход дает следующие преимущества:
нет дополнительных этапов сборки: каждый модуль может быть самостоятельным ендпоинтом
работает именно тот код, который вы написали, вы не разбираетесь с глюками sourceMap при дебаге
нет проблем со следованием стандартам (например, идентификаторы ESM содержат расширения файлов, в TS - это не обязательно)
JSDoc удобнее для комментирования + позволяет автоматизировать создание документации, для чего, собственно, он и был предназначен
Но, если вам такое не нравится - вы можете совершенно спокойно использовать синтаксис TypeScript, он вполне совместим с принципами JSDA.
AI
При работе с AI-помощниками в разработке мы сталкиваемся с одной проблемой, о которой, почему-то, пока мало говорят. AI - склонен воспроизводить популярные подходы и паттерны, без глубокого анализа их эффективности и применимости в конкретном случае. У AI пока очень плохо с тем самым упрощением, о котором я говорил ранее. React - стал слишком жирным и тормозным? Не важно, AI предложит использовать именно его, просто потому, что в сети больше примеров кода. В этом отношении, AI ведет себя как джуниор, не способный брать на себя ответственность за архитектурные решения, предполагающие выбор. При этом, сам AI, глобально, нуждается в оптимизации потребления токенов, и это вполне может находить отражение в технологиях, которые мы используем. Простота наших решений, включающая в себя сокращение сущностей, которыми мы оперируем - должна повлиять на ситуацию позитивно. Поэтому, важно создавать новые минималистичные паттерны, такие как JSDA, которые в целом должны положительно повлиять на индустрию.
Инструменты
До сих пор, я говорил о JSDA как о наборе соглашений и архитектурном принципе, без привязки к конкретным инструментам и не принуждающем использовать что-то конкретное. Конечно, чтобы все это кто-то использовал нужна экосистема решений, позволяющих быстро развернуть проект и максимально быстро получить первые результаты. Разработкой такой экосистемы я и занимаюсь в последнее время. Хотелось бы привлечь к этому сообщество и двигаться дальше - вместе.
Что есть на текущий момент:
JSDA Manifest - описание концепта
JSDA-Kit - изоморфная библиотека инструментов для работы с JSDA-стеком
Symbiote.js - библиотека для эффективной работы с веб-компонентами, позволяющая очень гибко работать с HTML
Из всего перечисленного, самым взрослым (production ready) проектом является Symbiote.js, остальное - в активной разработке, но попробовать можно уже сейчас.
В перспективе, планируется создание специализированной CDN, которая, помимо предварительной сборки и минификации модулей, будет автоматически выдавать готовые файлы в итоговом формате (HTML, CSS, SVG, Markdown, JSON и т. д.).
Сайт моей собственной R&D-студии, как и некоторые наши важные проекты, сделаны полностью на JSDA-стеке, этот подход УЖЕ доказал свою надежность и эффективность.
andreymal
Поворот веб-разработки не туда — это пустота вместо контента на полностью статическом сайте при отключенном JS, как же вы надоели
А конкретно сайт «R&D-студии» не только безосновательно неработоспособен без JS, но и даже со включенным JS старательно сопротивляется чтению, ломая родную браузерную прокрутку и пряча весь контент после прокрутки, чтобы я полсекунды сидел и пялился в пустую страницу, нервничая в ожидании завершения анимации появления контента
Пожалуйста, прекратите доламывать и без того сломанный веб
i360u Автор
Ваш комментарий как-то связан с темой статьи вообще? Если в ней есть что-то, что по вашему мнению, доламывает веб, напишите об этом, буду рад вам ответить на конструктивную критику. На сайт я даже ссылки не давал.
Контент на сайте доступен без JS. Не работают интерактивные виджеты на некоторых страницах, да, но это ваш выбор.
Минус не мой.
andreymal
То, что timestamp'ы в ссылках на файлы изменились на 21:21, намекает на то, что вы починили это уже после моего комментария (и после этого мой комментарий действительно стал не очень релевантен)
i360u Автор
А был он чему релевантен? Я повторюсь, как это связано с темой материала?
Мои эксперименты с дизайном на сайте, который почти никто не читает и который я чаще сам показываю клиентам на переговорах - это может и интересная тема для обсуждения, но каким боком она тут?
Если что, я сам большой сторонник только необходимого минимума js на сайтах.
Mr_FatCat
Меня больше смущает вот это загадочное 42.