State management для AstroJS

Выдался удобный случай попробовать создать небольшой сайт с помощью фреймворка AstroJS - магазин радиаторов velarshop.ru (больше просто онлайн-каталог).

В основе работы Astro лежит "островная архитектура": каждая страница сайта рассматривается не как единное целое, но как набор "островов", отвечающих за отображение своей части страницы. Большинство таких островов представляет собой сгенерированный при деплое статичный HTML, который передается клиенту сразу же. Острова, где требуется работа JavaScript, загружают его уже после отрисовки, добавляя себе нужную интерактивность. Таким образом удается достичь потрясающей скорости в работе приложения.

Например, такие оценки Google PageSpeed имеет страница товара

State management для AstroJS

Приятной особенностью является также то, что в качестве интерактивных компонентов могут использоваться как компоненты, использующие Vanilla JavaScript, так и компоненты, написанные с помощью React, Vue, Solid, Preact, Svelte.

Для своего стека я выбрал Preact, так как он максимально близок к знакомому мне React и имеет минимальные размеры.

Подобные компоненты желательно делать небольшими и располагать как можно дальше в структуре приложения. У нас получаются атомарные "острова", общающиеся между собой исключительно через общий state и через localStorage, когда речь идет о смене страниц.

Структура приложения

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

State здесь нужен только для сохранения инфорамции о том, какие товары добавленны в корзину, а также о выбанных посетителем опций (цвет радиатора, материал корпуса и т.п.).

Так как Astro - это набор отдельных страниц, то для сохранения state при переходах между страницами я использовал версию @nanostores/persistent. Данная библиотека синхронизирует данные с localStorage и обращается к нему за актуальными даными при каждой новой загрузке страницы.

Ниже приведена схема страницы с товаром и здесь обозначены основные блоки, требующие интерактива, меняющие или потребляющие данные из state.

State management для AstroJS

Красным обозначены блоки, меняющие state:

  • Опции товаров: цвет, тип подключения и т.п.
  • Добавление / удаление товаров в корзину

Синим выделены блоки, потребляющие state:

  • Наименования товаров и их цены, меняющиеся в зависимости от выбранных фильтров.
  • Корзина покупок, отображающая число и сумму добавленных в нее товаров.

Выбор опций товаров

Базовым элементом state являются опции товаров. От них зависит как цена, так и наименования позиций (item titles).

Для каждой опции создаем отдельную папку со следующей структурой:

│ ├── features │ ├── options │ │ ├── SelectConnection │ │ ├── SelectGrill │ │ ├── SelectColor │ │ │ ├── store │ │ │ │ ├── color.ts │ │ │ ├── SelectColor.tsx │ │ │ ├── index.ts │ │

Получаются изолированные папки, в каждой из которых содержится все необходимое: JSX компоненет, который можно расположить в любом месте сайта и кусок state в папке store. В случае с цветом, store выглядит так:

/src/features/options/SelectColor/store/store.ts import { сolors } from "@entities/Сolor" import { persistentAtom } from "@nanostores/persistent" import { computed } from 'nanostores' const version = await import.meta.env.PUBLIC_LOCAL_STORAGE_VERSION const colorId = persistentAtom<string>(`velarshop_color_active/${ version }`, "") const color = computed(colorId, (colorId) => { return colors.find((color) => color.id === colorId) }) const colorPostfix = computed(color, (color) => { if (!color) return '' return `, ${ color.name }` }) const colorPricePerSection = computed(color, (color) => { if (!color) return 0 return parseInt(color.price_section) }) export { colorId, colorPostfix, colorPricePerSection }

Здесь я использовал версию хранилища - version - на случай, если будет обновление прайс-листа или цветовой палитры, чтобы старые данные из localStorage не смешивались с обновленными.

Здесь есть только одна переменная colorId - собственно то, что выбирает пользователь. Остальные переменные являются производными от нее. Их значения автоматически пересчитываются каждый раз, когда пользователь меняет цвет.

  • color - содержит все данные о цвете. Выбирается из массива, на основании colorId; эта переменная не экспортируется и используется для получения следующих двух.
  • colorPrefix - высчитывается уже из color и используется в приложении для отображения item title (VelarP30HRAL9016 или VelarP30HRAL9005, например)
  • colorPricePerSection - цена покраски одной секции радиатора; используется при расчете конечной стоимости радиатора

Таким образом, у нас есть набор изолированных компонентов, которые можно разместить в удобном месте, чтобы пользователь выбирал подходящие для него опции.

colorPrefix просто добавляется в конце отображаемого title товаров.

colorPricePerSection используется немного сложнее и участвует в расчете конечной стомости каждого товара.

Расчет стоимости радиатора

Мы не можем просто добавить полученную стоимость покраски одной секции радиатора к итоговой стоимости, так как требуется знать еще и число секций.

Например, для чугунных радиаторов у нас выводится такая таблица:

State management для AstroJS

В каждом ряду выводится свое число секций от 3 до 15. Соотственно, общая стоимость покраски радиатора таже разная. Поэтому, мы не можем просто передавать значение цены покраски из store. Но, вместо этого, будем передавать функцию, рассчитывающую стоимость радиатора на основании данных о радиаторе, в том числе и количества секций.

Для этого создаем отдельный store, отвечающий за расчет цены товара на основе имеющихся параметров:

/src/features/item/ItemTotalCost/store/store.ts import { computed } from 'nanostores' import { colorPricePerSection } from '@features/options/SelectColor' .... // другие опции ... const getColorCost = computed(colorPricePerSection, (colorPricePerSection) => (model: ModelJson, radiator: RadiatorJson) => colorPricePerSection * (parseInt(radiator?.sections || "0")) )) ... const getItemTotalCost = computed( [ getColorCost, getConnectionCost, getSomeOtherCost ], ( getColorCost, getConnectionCost, getSomeOtherCost ) => (model: ModelJson, radiator: RadiatorJson) => ( getColorCost(model,radiator) + getConnectionCost(model,radiator) + ... ) ) export { getItemTotalCost }

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

Корзина покупок

Корзина покупок также вынесена в отдельную папку, содержащую элемент <BuyButton />, отвечающий за добавление товара в корзину и store, отвечающий за расчет общей суммы покупки.

Store выглядит следующим образом:

/src/features/order/ShoppingCart/store/store.ts import { persistentAtom } from "@nanostores/persistent" import { computed } from 'nanostores' import type { ShoppingCart } from "@entities/shopping-cart" const version = await import.meta.env.PUBLIC_LOCAL_STORAGE_VERSION const storeShoppingCart = persistentAtom<ShoppingCart>(`velarshop_shopping_cart/${ version }`, { items: [] }, { encode: JSON.stringify, decode: JSON.parse, }) const storeCartTotalPrice = computed(storeShoppingCart, (shoppingCart) => { return shoppingCart.items.reduce((total, item) => total + item.price * item.qnty, 0) }) const storeCartTotalQnty = computed(storeShoppingCart, (shoppingCart) => { return shoppingCart.items.reduce((total, item) => total + item.qnty, 0) }) const storeUniqueItemsQnty = computed(storeShoppingCart, (shoppingCart) => { return shoppingCart.items.length }) export { storeShoppingCart, storeCartTotalPrice, storeCartTotalQnty, storeUniqueItemsQnty }

Здесь та же логика: есть основная переменная - storeShoppingCart - где хранятся добавленные в корзину товары. И есть высчитываемые переменные, используемые для отображения данных в приложении.

Единственной особенностью является добавление encode / decode свойств при создании storeShoppingCart. Так как это не примитив, но массив объектов, то для его правильного хранения в local storage необходимо указать, каким образом преобразовывать данные перед их сохранением и при извлечении.

Итого

Работа с AstroJS оказалсь довольно простой и приятной. Необходимость встраивать JSX компоненты в виде изолированных блоков помогает поддерживать правильную архитектуру приложения в целом.

Если сравнивать с NextJS, то, по крайней мере для небольших и простых сайтов, Asto гораздо проще и приятней. Если добавить сюда еще и потрясающие показатели сайта на Google PageSpeed, то выбор в пользу данного фреймворка станет еще очевидней.

P.S. У меня еще не было опыта работы с новыми функциями Next13 (с папкой app). Поэтому сравнение с Astro выходит не совсем честным.

Начать дискуссию