odnaco.me: магазин лётной экипировки — от RiotJS к веб-компонентам
Комбинезон собирается из размера, цвета, варианта и опций прямо на странице, цену считает сервер, а весь JavaScript сайта — 18,7 КБ после сжатия.

Магазин без платформы
Odnaco шьёт снаряжение для парапланеристов: лётные комбинезоны, концертины для укладки крыла, «пончики» — чехлы для быстрой упаковки — и мелкие аксессуары. Каталог на сайте появился в 2015 году, заказы через сайт идут с 2017-го.
Готовая платформа магазина здесь была бы лишней. Товаров два десятка, заметная часть шьётся под заказ, и заказ — это не «артикул x количество», а комбинезон конкретного размера, цвета и комплектации. Поэтому всё написано на ProcessWire с нуля: товары и их опции — обычные страницы и поля в админке, корзина хранится в сессии на сервере, оформленный заказ становится страницей в разделе заказов, а письма уходят менеджеру и покупателю.
Оплата — наличными, переводом на карту или по счёту, без онлайн-эквайринга: для магазина такого масштаба этого достаточно.
Каталог


Товар как конструктор
В ролике выше — комбинезон «Полусамосброс». У него шесть размеров, включая пошив по меркам, девять цветов, три исполнения — летнее, демисезонное и зимнее — и восемь дополнительных опций, от шлёвок до съёмного капюшона. Каждый выбор сразу пересчитывает цену на кнопке: за пять нажатий 19 000 ₽ превращаются в 21 700 ₽.
В админке всё это — повторяющиеся поля самой страницы товара: у цвета есть название и оттенок, у исполнения и опции — название и надбавка. Менеджер добавляет опцию или снимает её с публикации, и конфигуратор узнаёт об этом при следующей загрузке страницы, без правки кода.
Цену считает сервер. Браузер присылает в корзину только выбор — идентификаторы товара, цвета, исполнения и опций, — а стоимость складывается из цен, записанных на страницах. Сумма на кнопке — подсказка покупателю, а не цифра, которой верит магазин.
Один тег в шаблоне
<!-- product.php: the whole configurator is one tag -->
<product-configurator uid="<?=$page->uid?>"></product-configurator>
// components never call each other: they talk over one small bus
AppBus.trigger('cart_add', uid, config) // the button, on click
// AppStore is the only code that talks to the server,
// and what it sends is a choice of ids, never a price
post('/api/cart/add', { uid, config })
// config: {"size":"M","color":"…","variant":"…","options":["…","…"]} Компоненты не знают друг о друге: кнопка объявляет событие, запрос к серверу отправляет одно общее хранилище.
От RiotJS к веб-компонентам
Первая версия фронтенда была написана на RiotJS 3: компоненты-теги в файлах .tag, собранные riot-compiler, связь между ними через RiotControl и jQuery для всего остального. Для 2017 года — разумный выбор. К 2026-му третья версия Riot фактически умерла и плохо уживалась с современными инструментами сборки.
Мы переписали фронтенд на нативные веб-компоненты — то, что браузер умеет сам, без библиотеки. Теги сменили шесть элементов: <product-configurator>, <cart-add>, <cart-view>, <mini-cart>, <ajax-indicator> и <svg-sprite>. Shadow DOM не понадобился: компоненты рисуют обычную разметку и пользуются общими стилями сайта, как и раньше. Вместо RiotControl — маленькая шина событий, вместо jQuery — урезанная до 27 методов копия библиотеки cash: ровно то, что сайт действительно вызывает.
Последним шагом, в августе 2026 года, старая сборка на gulp уступила место нашей: esbuild, стили, спрайт иконок и имена файлов с хэшем, чтобы браузер хранил их год и никогда не получал устаревших. Весь JavaScript сайта теперь — один файл в 18,7 КБ после сжатия, и фреймворка в нём нет.
Корзина и оформление


Каталог отдаётся готовыми файлами
Страницы каталога меняются редко, а открываются часто, поэтому их отдаёт не PHP, а Apache — готовыми HTML-файлами из статического кэша StaticPages. Страница товара весит 3,8 КБ после сжатия и приходит без cookie сессии.
При этом магазину нужна защита от подделки запросов, а общий для всех файл не может хранить личный токен посетителя. Поэтому StaticPages вырезает токен перед сохранением страницы, а скрипт получает свежий первым же запросом к API, вместе с данными товара и корзины. Любой запрос, меняющий корзину, идёт с этим токеном; без него сервер отвечает отказом.
Картинки приходят в формате, который понимает браузер: фото шириной 800 пикселей весит 17 КБ в AVIF, 19 КБ в WebP и 37 КБ в JPEG — вместо 286 КБ оригинала.


