Эту статью подготовил Алекс Попуцис.
Сегодня я рассмотрю, как можно объединить две новые, быстро развивающиеся веб-технологии, чтобы быстро создать веб-приложение и настроить его на бесконечное масштабирование.
Эти две технологии — Svelte и Fauna. Svelte — это одна из новейших технологий в мире разработки фронтенда, основанная на том, что уже сделали React, Angular, Vue и другие, но при этом значительно упрощающая ситуацию. Если вы новичок в Svelte, я рекомендую начать с их учебника здесь. Fauna — это современная, основанная на документах, бессерверная база данных, которая работает в облаке и предлагает щедрый бесплатный уровень для начала работы. Вот ссылка на документацию Fauna, если вам нужно ознакомиться с ней, прежде чем приступать к этому руководству.
В этом учебнике будут рассмотрены все основы, необходимые для создания небольшого сайта электронной коммерции с использованием Fauna и Svelte. Вы узнаете:
- Как создать базу данных Fauna и наполнить ее коллекциями, индексами и документами.
- Основные принципы проектирования баз данных Fauna и язык запросов Fauna Query Language (FQL)
- Как подключить приложение Svelte к Fauna и отображать данные
- Как реализовать базовую корзину для приложения электронной коммерции Svelte
- Как записывать данные обратно в Fauna из нашего приложения Svelte
Начало работы
Перейдите на сайт fauna.com и зарегистрируйте новую учетную запись. На приборной панели нажмите кнопку Создать базу данных. Наш сайт электронной коммерции будет предназначен для пекарни, поэтому я назвал свою базу данных SvelteBakery и поместил ее в группу регионов США:
Как только база данных будет запущена, мы создадим пару коллекций — эквивалент таблицы в традиционной реляционной базе данных. Я выбрал значение по умолчанию — 30 дней сохраненной истории для каждого документа (эквивалент строки) в коллекции, и TTL не задан. Указание TTL приведет к удалению записей, которые оставались нетронутыми в течение определенного количества дней. Я создаю одну коллекцию для категорий товаров, одну для самих товаров и одну для хранения данных о заказах.
Далее я собираюсь вставить некоторые данные в мою базу данных. Я начну с коллекции Categories, щелкну в ней, а затем нажму New Document. Если вы знакомы с JSON, это будет довольно просто, вы можете поместить любой JSON, который хотите, внутрь скобок. Например, чтобы добавить категорию под названием «Десерт», вы можете сделать следующее:
Вы также можете использовать оболочку (либо внутри приборной панели Fauna, либо с помощью пакета fauna-shell NPM) или любой язык, поддерживающий API Fauna, который я использовал в данном случае для массового добавления элементов:
Create(Collection("Categories"), { data: { name: "Sandwiches" } })
Create(Collection("Categories"), { data: { name: "Sides" } })
Create(Collection("Categories"), { data: { name: "Beverages" } })
Create(Collection("Categories"), { data: { name: "Desserts" } })
Мы получим обратно вставленные данные, с уникальной ссылкой для каждого, которую нам нужно будет использовать позже для добавления элементов:
[
{
ref: Ref(Collection("Categories"), "322794748885598273"),
ts: 1644099911490000,
data: {
name: "Sandwiches"
}
},
{
ref: Ref(Collection("Categories"), "322794748889792577"),
ts: 1644099911492000,
data: {
name: "Sides"
}
},
{
ref: Ref(Collection("Categories"), "322794748889793601"),
ts: 1644099911495000,
data: {
name: "Beverages"
}
},
{
ref: Ref(Collection("Categories"), "322794748889794625"),
ts: 1644099911497000,
data: {
name: "Desserts"
}
}
]
Далее давайте создадим товар. Перейдите в коллекцию Products и начнем с сэндвича BLT:
Название и цена выглядят довольно просто, но что происходит с этой категорией? Это то, что в Fauna называется ссылкой. По сути, мы говорим базе данных обратиться к коллекции Categories и вернуть документ с идентификатором 322794748885598273.
Использование идентификатора в качестве ссылки вместо простого добавления названия категории упрощает последующее изменение сведений о категории. Например, если я захочу изменить название категории с «Напитки» на «Напитки», я смогу сделать это в коллекции Categories, не обращаясь к коллекции Products.
Далее я собираюсь выполнить массовое добавление товаров в базу данных. Я сделаю это через оболочку Fauna, используя функции Fauna Map и Lambda:
Map(
[
['BLT', 6.99, '322794748885598273'],
['Turkey and Swiss', 7.99, '322794748885598273'],
['Egg, Bacon, and Cheese', 4.99, '322794748885598273'],
['Chicken Bacon Ranch', 7.99, '322794748885598273'],
['Grilled Cheese', 5.99, '322794748885598273'],
['Potato Chips', 1.25, '322794748889792577'],
['Mac and Cheese', 3.99, '322794748889792577'],
['Potato Salad', 2.99, '322794748889792577'],
['Soup of the Day', 3.99, '322794748889792577'],
['Bottled Water', 1.50, '322794748889793601'],
['Fountain Drink', 1.99, '322794748889793601'],
['Coffee', 1.99, '322794748889793601'],
['Cold Brew', 2.99, '322794748889793601'],
['Espresso', 3.99, '322794748889793601'],
['Blueberry Muffin', 1.99, '322794748889794625'],
['Chocolate Chip Muffin', 1.99, '322794748889794625'],
['Croissant', 1.49, '322794748889794625'],
['Brownie', 1.49, '322794748889794625'],
['Chocolate Chip Cookie', .99, '322794748889794625'],
['Slice of Cheesecake', 2.99, '322794748889794625']
],
Lambda(
['name', 'price', 'category'],
Create(
Collection('Products'),
{
data: {
name: Var('name'),
price: Var('price'),
category: Ref(Collection('Categories'), Var('category'))
}
}
)
)
)
Если вы знакомы с функцией map в JavaScript, это может показаться вам знакомым. По сути, я создал массив массивов, а функция map перебирает внешний массив и передает каждый внутренний массив в функцию Lambda для обработки. Лямбда разрушает внутренний массив на составляющие: название, цену и идентификатор категории. Затем я беру эти данные и передаю их в функцию Create, чтобы добавить новый документ в коллекцию Products. Это позволило мне добавить все эти строки менее чем за полсекунды и избавило меня от лишнего набора текста.
Далее я собираюсь создать несколько индексов, чтобы помочь в поиске данных. Во-первых, нам понадобится индекс для all_categories, поскольку именно так мы будем группировать наше меню во фронтенде. Выполните следующие действия в консоли Fauna:
CreateIndex({
name: "all_categories",
source: Collection("Categories"),
values: [
{ field: ["ref"] },
{ field: ["data", "name"] }
]
})
Это создаст индекс all_categories
, который будет возвращать ID и название каждой категории. Я перечислил ссылку (ID) первой, потому что мы будем использовать ее для сортировки.
Далее я собираюсь создать основной индекс, который мы будем использовать в приложении. Я планирую иметь меню продуктов, сгруппированных по категориям. Поэтому нам нужен индекс, в котором полем для поиска будет категория, а возвращаемыми значениями — названия продуктов и цены. Вот пример того, как создать индекс в графическом интерфейсе приборной панели, а не в консоли:
Теперь, когда я возвращаюсь к этому индексу, я могу использовать опцию FQL для поиска в нем. На скриншоте фрагмент кода обрезан, но это просто Ref(Collection('Categories'), '322794748885598273')
.
Это вернет название и цену для каждого товара в категории 322794748885598273, которая называется «Бутерброды».
Безопасность
Есть еще одна вещь, которую нам нужно сделать на стороне базы данных, прежде чем мы перейдем к созданию нашего приложения. Нам нужно будет создать роль в Fauna, которую мы сможем использовать для получения данных. На вкладке безопасности в приборной панели выберите Роли и нажмите Новая пользовательская роль. Я использовал здесь название Svelte
, но вы можете выбрать любое другое. Затем добавьте все три коллекции и индексы с помощью выпадающих окон. Дайте товарам и категориям разрешение на чтение, установив флажок в этом столбце, а заказам дайте разрешение на создание. Затем дайте обоим индексам разрешение на чтение.
Здесь мы говорим Fauna, что хотим получить доступ на чтение к нашим продуктам и категориям, чтобы заполнить меню на фронтенде. Мы также хотим иметь возможность записывать новые заказы в коллекцию заказов, когда заказ отправлен. Наконец, мы хотим иметь доступ только для чтения к нашим индексам. Мы не хотим предоставлять этой роли доступ для выполнения каких-либо неблаговидных действий, например, создания новых продуктов в коллекции Products или удаления Categories, поэтому здесь важно быть очень разборчивыми.
После этого вернитесь на вкладку Security, выберите Keys и создайте новый ключ, используя роль Svelte:
Вы получите ключ обратно, когда отправите его; храните его в безопасном месте, потому что вы увидите его только один раз.
Создание фронтенда
Теперь у нас достаточно настроек в базе данных, чтобы начать работу над нашим приложением Svelte. В Visual Studio Code я открыл созданную мной папку под названием SvelteBakery, а затем выполнил следующие команды терминала, чтобы запустить свой проект:
npx degit sveltejs/template .
npm install
Внутри папки src я также создал вложенную папку components и вложенную папку stores. Структура папок должна выглядеть следующим образом:
Начнем с папки stores. Здесь мы создадим файл stores.js
и заполним его следующим кодом:
import { writable } from ‘svelte/store’
export const shoppingCart = writable([])
Svelte позволяет очень легко обмениваться данными между компонентами с помощью этих встроенных хранилищ. Если вы пришли из другого фреймворка, например, React, вы, возможно, привыкли использовать что-то вроде Redux для управления состоянием, но Svelte включает это из коробки. Мы будем использовать этот магазин позже в учебнике для отслеживания товаров в нашей корзине.
Я также создаю магазин, чтобы облегчить совместное использование нашего клиента Fauna в разных компонентах. Для этого я создал файл fauna.js
в той же папке stores и заполнил его похожим кодом:
import { writable } from 'svelte/store'
export const fauna = writable({})
Единственная реальная разница заключается в том, что shoppingCart был инициализирован пустым массивом, а магазин fauna был инициализирован пустым объектом.
Я собираюсь использовать это в нашем основном файле App.svelte, который теперь выглядит следующим образом:
<script>
import ProductMenu from "./components/ProductMenu.svelte";
import ShoppingCart from "./components/ShoppingCart.svelte";
import Nav from "./components/Nav.svelte";
import { fauna } from './stores/fauna';
let currentPage = "menu";
function connectToFauna() {
connectToFauna = () => {};
fauna.set(
{
client: new window.faunadb.Client({ domain: 'db.us.fauna.com', scheme: 'https', secret: "INSERT_SECRET_HERE" }),
q: window.faunadb.query
}
)
}
function handlePageChange(e) {
currentPage = e.detail.newPage;
}
</script>
<main>
<Nav on:changepage={handlePageChange} />
{#if currentPage === "menu"}
<ProductMenu />
{:else if currentPage === "cart"}
<ShoppingCart />
{/if}
</main>
<svelte:head>
<script src="//cdn.jsdelivr.net/npm/faunadb@latest/dist/faunadb-min.js" on:load={connectToFauna}></script>
</svelte:head>
<style>
main {
text-align: center;
padding: 1em;
margin: 0 auto;
}
</style>
Здесь нужно отметить несколько моментов:
- В верхней части у меня есть четыре импорта:
ProductMenu
,ShoppingCart
, иNav
(три компонента Svelte, которые мы создадим ниже), и наш клиентский магазин Fauna, который мы создали выше. - Ниже я использую блок
svelte:head
. Это позволяет вставлять код в раздел head скомпилированного HTML. В данном случае я решил использовать драйвер Fauna JavaScript из CDN, а не пытаться установить его из npm и работать с полифиллом. - У меня есть параметр
onload
, так что после загрузки драйвера Fauna функцияconnectToFauna
будет запущена и создаст соединение. Заслуга этой функции принадлежит Svelte REPL, которую я нашел здесь. - Одна проблема, с которой я столкнулся, связана с доменом. В зависимости от того, в каком регионе находится ваша база данных Fauna, вам нужно будет настроить это соответствующим образом — каждая группа регионов имеет свой собственный поддомен, который вы должны использовать, иначе вы будете в недоумении, почему вы не можете получить доступ к своим данным.
- В дополнение к домену вам нужно будет вставить секретный ключ, созданный ранее для роли базы данных Svelte. Заполните его там, где написано «INSERT_SECRET_HERE».
- У меня есть функция
handlePageChange
и блок if в самом HTML, чтобы определить, отображать ли меню или корзину. Подробнее об этом я расскажу ниже.
Далее перейдем в папку компонентов и начнем создавать макет. Наше приложение будет довольно простым, с навигационной панелью в верхней части для переключения между меню и процессом покупки/оформления заказа. Внутри папки components давайте создадим следующие пустые файлы:
Nav.svelte
ProductMenu.svelte
MenuSection.svelte
MenuItem.svelte
ShoppingCart.svelte
Начнем с компонента Nav, поскольку он позволит нам переходить от меню товара к корзине и обратно. Вот полный код для Nav:
<script>
import { shoppingCart } from '../stores/shoppingCart';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function changePage(newPage) {
if (newPage === "menu" || $shoppingCart.length > 0)
dispatch('changepage', {
newPage: newPage
});
}
</script>
<nav>
<h1>Svelte Bakery Demo</h1>
<a href="#" on:click={() => changePage("menu")}>Menu</a>
<a href="#" on:click={() => changePage("cart")}>Cart
{#if $shoppingCart.length > 0}({$shoppingCart.length} items){/if}
</a>
</nav>
<style>
nav {
display: flex;
color: #FFF;
background-color: rgb(14, 14, 46);
flex-direction: column;
align-items: center;
}
@media(min-width: 768px) {
nav {
flex-direction: row;
gap: 3rem;
}
}
h1 {
flex-basis: 50%;
}
a {
text-decoration: none;
color: #FFF;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
</style>
В разделе сценария я использую createEventDispatcher
для отправки пользовательского события родителю (App.svelte). При нажатии на одну из ссылок вызывается changePage
, и эта функция посылает событие главному приложению с новым именем страницы. Вспомните этот код выше из App.svelte
, когда мы поместили наш компонент Nav
в HTML:
<Nav on:changepage={handlePageChange} />
Вы можете видеть, что я настроил его на вызов функции handlePageChange
в App.svelte
при отправке события changepage
. Это обновит переменную currentPage
в App.svelte
, которая определяет, видит ли конечный пользователь меню или корзину.
В дополнение к функциональности, связанной с изменением страниц, вы также можете увидеть еще один блок if в Nav.svelte
. Я импортировал свой магазин корзины и использую shoppingCart.length
в нескольких местах, чтобы определить, показывать ли количество товаров в корзине в навигационной панели, а также должна ли ссылка на корзину вообще что-то делать — мы не будем направлять пользователя в корзину, если она в данный момент пуста. Переменные магазина должны иметь префикс со знаком доллара, чтобы получить к ним доступ, но в остальном они работают как обычные переменные для чтения, что очень упрощает работу.
Далее мы приступим к созданию собственно меню продуктов. Компонент ProductMenu будет нашей оберткой для меню, и он будет содержать компонент MenuSection для каждой категории, содержащей наши товары. Внутри каждого MenuSection будут отдельные компоненты MenuItem для каждого продукта. Давайте начнем с самого верхнего уровня, ProductMenu, и будем двигаться вниз:
<script>
import MenuSection from './MenuSection.svelte';
import { fauna } from '../stores/fauna';
let loaded = false;
let categories = [];
$: {
if ($fauna.client && !loaded) {
loaded = true;
$fauna.client.query(
$fauna.q.Paginate(
$fauna.q.Match(
$fauna.q.Index('all_categories')
)
)
)
.then((res) => {
categories = res.data;
})
}
}
</script>
<section>
<h2>Menu</h2>
{#each categories as category}
<MenuSection {category} />
{/each}
</section>
Здесь, в ProductMenu.svelte
, я импортирую наш клиент Fauna из его магазина, а также компонент MenuSection, который будет следующим уровнем ниже. Затем я инстанцировал пару переменных, одну для хранения состояния загрузки и одну для хранения списка категорий. Я также проверил состояние загрузки, потому что не хочу пытаться запрашивать Fauna, пока клиент не будет готов к работе.
Затем я использую реактивный блок ($:
в Svelte указывает на блок кода, который будет выполняться, когда что-то внутри него изменится, в данном случае, когда изменится хранилище $fauna.client
). Этот блок запросит индекс all_categories
и вернет id
и name
каждой категории. Я заполняю массив categories этими возвращенными данными. Ниже я использую блок Svelte each для итерации по массиву категорий. Каждая категория порождает компонент MenuSection с категорией, переданной в качестве параметра. Давайте рассмотрим MenuSection дальше.
<script>
import MenuItem from './MenuItem.svelte';
import { onMount } from 'svelte';
import { fauna } from '../stores/fauna';
export let category;
let [ ref, name ] = category;
let items = [];
onMount(async () => {
let x = await $fauna.client.query(
$fauna.q.Paginate(
$fauna.q.Match(
$fauna.q.Index('product_by_category'),
ref
)
)
);
items = x.data;
})
</script>
<div>
<h3>{name}</h3>
<div class="items">
{#each items as item}
<MenuItem {item} />
{/each}
</div>
</div>
<style>
.items {
display: flex;
gap: 2rem;
justify-content: center;
flex-wrap: wrap;
}
</style>
Этот код дает нам одну вещь, которую мы еще не видели в предыдущих компонентах Svelte: оператор export let
. Export let определяет параметр, который передается в этот компонент его родителем. В данном случае мы определили категорию как то, что будет передано в компонент. Вы могли заметить, что компонент MenuSection был вставлен в меню ProductMenu с помощью <MenuSection {category} />
. Поскольку переданная переменная имеет имя category, а компонент ожидает одно имя category, я смог заключить category в скобки и оставить все как есть. Если бы переменная имела другое имя в ProductMenu
, например, c
, мне пришлось бы набрать <MenuSection category={c} />
вместо этого. Этот приятный синтаксический сахар специфичен для Svelte.
Вернемся к компоненту MenuSection
— здесь мы также используем onMount
. Это один из методов жизненного цикла Svelte, который позволяет нам запускать код, когда компонент был смонтирован в DOM. В отличие от ProductMenu
, мы знаем, что к монтированию MenuSection
у нас будет инстанцированный клиент Fauna, поэтому нам не нужно использовать обходной путь реактивного оператора, который мы создали ранее. В этом случае мы можем чисто вызвать клиента Fauna внутри onMount
.
Здесь я использую индекс product_by_category
, который позволяет нам искать по category_id
. Поскольку мы передали эти данные в компонент вместе с названием категории, мы можем сделать запрос, чтобы получить все продукты в определенной категории. Затем я использую еще один блок each для итерации и отправки каждого из них в компонент MenuItem
. Посмотрите на последнюю часть меню, MenuItem
:
<script>
import { shoppingCart } from "../stores/shoppingCart";
export let item;
let [ name, price ] = item;
function addToCart() {
shoppingCart.update(n => {
return [ ...n, {name: name, price: price}];
});
}
</script>
<div>
<h4>{name}</h4>
<strong> ${price.toFixed(2)}</strong>
<button on:click={addToCart}>Add to cart</button>
</div>
<style>
div {
display: flex;
flex-direction: column;
width: 90%;
border: 1px solid #000;
border-radius: 5px;
}
@media(min-width: 768px) {
div {
width: 15%;
}
}
button {
width: fit-content;
margin: 1rem auto;
}
</style>
В разделе сценария я импортировал магазин shoppingCart
, так как здесь мы будем записывать товары в корзину по мере их добавления. У меня также снова есть объявление export let
, так как конкретный объект элемента будет передан из MenuSection
. Наконец, я определяю функцию addToCart
, которая добавит товар в магазин shoppingCart
с помощью метода shoppingCart.update
.
HTML и CSS довольно просты; я лишь обращу особое внимание на on:click
, объявленный для кнопки. Это синтаксис Svelte для размещения обработчика события на элементе. Поскольку название товара и цена привязаны к компоненту, мне не нужно беспокоиться о передаче параметров в функцию, так как они уже известны.
Мы приближаемся к концу! Теперь давайте рассмотрим процессы оформления заказа и корзины. Вот ShoppingCart.svelte
:
<script>
import { shoppingCart } from "../stores/shoppingCart";
import { fauna } from '../stores/fauna';
const reducer = (previousValue, currentValue) => previousValue + parseFloat(currentValue.price);
let total;
let customerName;
let confirmed = false;
$: total = $shoppingCart.reduce(reducer, 0);
function removeItem(idx) {
shoppingCart.update(n => {
if (idx === 0) return [...n.slice(1)];
if (idx === n.length - 1) return [...n.slice(0, n.length - 1)]
return [...n.slice(0, idx), ...n.slice(idx+1)]
})
}
function submitOrder() {
console.log(customerName);
$fauna.client.query(
$fauna.q.Create(
$fauna.q.Collection('Orders'),
{ data: { customerName: customerName, details: $shoppingCart, total: total } }
)
).then(() => {
confirmed = true;
})
}
</script>
<section>
<h2>Shopping Cart</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody>
{#each $shoppingCart as item, idx}
<tr>
<td>{item.name}</td>
<td>${item.price.toFixed(2)}</td>
<td>
<button on:click={() => removeItem(idx)}>Remove</button>
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td>${total.toFixed(2)}</td>
<td></td>
</tr>
</tfoot>
</table>
{#if !confirmed}
<form on:submit|preventDefault={submitOrder}>
<label for="name">Name: </label>
<input type="text" id="name" bind:value={customerName} />
<input type="submit" />
</form>
{:else}
<div>Thank you very much for your order!</div>
{/if}
</section>
<style>
section, table {
margin: 0 auto;
}
table {
border: 1px solid #000;
border-collapse: collapse;
}
td, th {
padding: 1rem;
}
tr:nth-child(even) {
background: rgb(172, 170, 170);
}
thead, tfoot {
font-weight: bold;
}
</style>
На этот раз мы импортируем оба наших магазина, поскольку нам понадобится shoppingCart
для чтения данных, которые мы положили в корзину, и нам понадобится драйвер Fauna для создания нового документа в коллекции Orders. Я использую редуктор для суммирования цен на все товары в корзине и получения одного итогового значения.
В разделе HTML я создал таблицу, в которой будут содержаться данные о моей корзине. В каждой строке также есть кнопка, вызывающая функцию removeItem
, которая удалит этот товар из корзины. В отличие от блоков each
, которые я использовал ранее, в этом блоке я также использовал индекс, так как это позволяет мне отслеживать индекс для каждой строки в таблице, чтобы я мог использовать это значение для удаления нужной записи из моего магазина shoppingCart
.
Наконец, у меня есть условная секция, которая покажет либо форму заказа, если заказ не был отправлен, либо текст подтверждения, если он был отправлен. Отправка формы вызывает submitOrder
, которая использует функцию Create
в Fauna для добавления нового документа в нашу коллекцию Orders (напомню, что роль, которую мы создали ранее, позволяет это сделать). Я использую привязку формы Svelte для привязки ввода имени к переменной customerName, что делает работу с формами чрезвычайно удобной.
После тестового запуска корзины вот пример конечных данных в коллекции Orders:
{
"ref": Ref(Collection("Orders"), "321462934244950082"),
"ts": 1642829794070000,
"data": {
"customerName": "Alexander Popoutsis",
"details": [
{
"name": "Potato Chips",
"price": 1.25
},
{
"name": "Potato Salad",
"price": 2.99
},
{
"name": "Soup of the Day",
"price": 3.99
}
],
"total": 8.23
}
}
Следующие шаги
Если вы хотите увидеть полный код в одном месте, он находится на GitHub.
Конечно, это базовая демонстрация, и есть много вещей, которые можно сделать более продвинутыми (например, обработка количества товаров и улучшение стиля). Кроме того, поскольку это полностью клиентское приложение, ключ Fauna виден конечным пользователям, а безопасность особенно важна. Чтобы добавить компонент на стороне сервера, вы можете использовать SvelteKit и спрятать части Fauna за конечной точкой API SvelteKit.
Теперь у вас есть все необходимое, чтобы начать работу с базовым приложением, используя фантастическое сочетание Fauna и Svelte. Если вы еще не сделали этого, нажмите здесь, чтобы зарегистрировать учетную запись Fauna. А если вы вдруг застрянете, сообщество Fauna — это фантастический ресурс.