В прошлых частях этой серии статей я занимался изучением Vue.js и сделал интернет-магазин винила, но всё сделано было на чистом фронте: данные подгружались из json файла в том же проекте, заказы размещались там же, и это не лучшее решение.
В этой статье я разделю бэк и фронт проекта: в бэке на Java (Sping) будет api, к которому мы пропишем обращения во фронте.
Обзор проекта и постановка задачи
Для тех, кому лень читать прошлые статьи, напомню, что в проекте есть Shop.vue – область магазина, в которой появляется или CataloguePage.vue – каталог товаров, или ProductPage.vue – страница одного товара со всей информацией. И из каталога и со страницы товаров их можно добавлять в корзину и потом перейти на CheckoutPage.vue. После успешного размещения товаров мы переходим на CheckoutSuccessPage.vue с подтверждением заказа.
В проекте данные лежат в products.json в папке public/data. Рядом в public/img лежат изображения. Формат данных следующий:
{
"id": 23,
"artist": "Madonna",
"name": "True Blue",
"catNo": "MADONNA-TRUEBLUE",
"format": "vinyl",
"note": "classic reissue",
"img": "/img/Madonna_-_True_Blue_(album_cover).png",
"price": 24,
"description": "One of Madonna’s defining 80s pop albums, full of sparkling production and infectious melodies. It captures her artistic confidence and remains a cornerstone of her early career."
}
Достаются данные на страницах следующим образом:
fetch('/data/products.json')
Цель теперь перенести данные в бэк и создать api с эндпойнтами, которые будут возвращать каталог товаров, конкретный товар для страницы товара, а также принимать данные о пользователе и его заказе. Vue не должен знать, откуда берутся товары – он просто их отображает.
Контракт API (эндпойнты)
Products
- GET /api/products
query params:
q (поиск по artist/name)
maxPrice
format
sort = priceAsc|priceDesc|artistAsc|random
limit, offset (опционально)
GET /api/products/{id}
Orders
POST /api/orders - принимает новый заказ
Ответ: orderId, total, createdAt
Перейдём к реализации такого api.
REST api на Java Spring
Я не буду в деталях расписывать, что я делаю, потому что это не фокус данной статьи – о REST я писал ранее несколько раз. Сразу перейду к результату.
Такую структуру проекта я получил в итоге:
winylka/ (root)
├─ pom.xml
├─ src/
│ ├─ main/
│ │ ├─ java/
│ │ │ └─ winylka/
│ │ │ ├─ WinylkaApplication.java
│ │ │ ├─ api/
│ │ │ │ ├─ ProductController.java
│ │ │ │ └─ OrderController.java
│ │ │ ├─ service/
│ │ │ │ ├─ ProductService.java
│ │ │ │ └─ OrderService.java
│ │ │ ├─ infra/
│ │ │ │ └─ OrderStorage.java
│ │ │ └─ model/
│ │ │ ├─ Product.java
│ │ │ ├─ OrderRequest.java
│ │ │ ├─ OrderItemRequest.java
│ │ │ ├─ OrderResponse.java
│ │ │ └─ (Customer / Shipping DTO если у тебя отдельными классами)
│ │ └─ resources/
│ │ ├─ application.properties
│ │ ├─ data/
│ │ │ └─ products.json
│ │ └─ static/
│ │ └─ img/
│ │ ├─ Madonna_-_True_Blue_(album_cover).png
│ │ ├─ Lana_Del_Rey_-_Norman_Fucking_Rockwell.png
│ │ └─ ... (остальные обложки)
│ └─ test/
│ └─ java/
│ └─ winylka/
│ └─ WinylkaApplicationTests.java
└─ README.md
Сначала покажу, как я достаю данные – оставил тот же .json файл, чтобы не разворачивать базу ради проекта. В JsonProductRepository читаю файл и делаю маппинг с помощью jackson.
@Component
public class JsonProductRepository {
private final ObjectMapper mapper = new ObjectMapper();
public List<Product> loadAll() {
try {
ClassPathResource res = new ClassPathResource("data/products.json");
try (InputStream is = res.getInputStream()) {
return mapper.readValue(is, new TypeReference<List<Product>>() {});
}
} catch (Exception e) {
throw new IllegalStateException("Failed to load data/products.json", e);
}
}
}
Сами объекты я кэширую в сервисе, чтобы не обращаться к файлу при каждом поиске:
@Service
public class ProductService {
private final JsonProductRepository repo;
private List<Product> cached;
public ProductService(JsonProductRepository repo) {
this.repo = repo;
this.cached = repo.loadAll();
}
public List<Product> findAll(String q, Integer maxPrice, String format,
String sort, Integer limit, Integer offset) {
Stream<Product> s = cached.stream();
if (q != null && !q.trim().isEmpty()) {
String needle = q.trim().toLowerCase();
s = s.filter(p -> (p.getArtist() + " " + p.getName()).toLowerCase().contains(needle));
}
if (maxPrice != null) {
s = s.filter(p -> p.getPrice() <= maxPrice);
}
if (format != null && !format.isBlank()) {
String fmt = format.trim().toLowerCase();
s = s.filter(p -> p.getFormat() != null && p.getFormat().toLowerCase().equals(fmt));
}
List<Product> list = s.collect(Collectors.toList());
// сортировка
if (sort != null) {
switch (sort) {
case "priceAsc" -> list.sort(Comparator.comparingInt(Product::getPrice));
case "priceDesc" -> list.sort(Comparator.comparingInt(Product::getPrice).reversed());
case "artistAsc" -> list.sort(Comparator.comparing(p -> safe(p.getArtist())));
case "random" -> Collections.shuffle(list);
}
}
int off = offset == null ? 0 : Math.max(0, offset);
int lim = limit == null ? list.size() : Math.max(0, limit);
if (off >= list.size()) return List.of();
return list.subList(off, Math.min(list.size(), off + lim));
}
public Product findById(int id) {
return cached.stream()
.filter(p -> p.getId() == id)
.findFirst()
.orElse(null);
}
private static String safe(String s) {
return s == null ? "" : s.toLowerCase();
}
}
И тогда контроллер с двумя эндпойнтами выходит такой:
@RestController
@RequestMapping("/api/products")
@CrossOrigin
public class ProductController {
private final ProductService products;
public ProductController(ProductService products) {
this.products = products;
}
@GetMapping
public List<Product> list(
@RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxPrice,
@RequestParam(required = false) String format,
@RequestParam(required = false) String sort,
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) Integer offset
) {
return products.findAll(q, maxPrice, format, sort, limit, offset);
}
@GetMapping("/{id}")
public ResponseEntity<Product> one(@PathVariable int id) {
Product p = products.findById(id);
return (p == null) ? ResponseEntity.notFound().build() : ResponseEntity.ok(p);
}
}
Аннотация @CrossOrigin
Здесь нужно обратить внимание на аннотацию @CrossOrigin: мы разрешаем обращаться к этому ресурсу с другого домена/порта (порты у нас разные, так как vite + Vue на 5173, а джава на 8080). Без аннотации браузер бы не позволил фронту связаться с бэком по соображениям безопасности и даже если бы сервер вернул 200 — ОК, то в консоли браузера увидели бы сообщение:
CORS policy: No 'Access-Control-Allow-Origin' header...
Благодаря аннотации Spring автоматически добавляет в ответ заголовок:
Access-Control-Allow-Origin: *
Можно было ещё явно указать, кому мы разрешаем обращаться к этому контроллеру:
@CrossOrigin(origins = "http://localhost:5173")
Тогда заголовок стал бы:
Access-Control-Allow-Origin: http://localhost:5173
Далее в статье я покажу, как можно ещё решить проблему с CrossOrigin, но уже с помощью vite proxy. А пока вернусь к демонстрации кода бэка:
Теперь на очереди контроллер заказов – здесь у нас нет get-методов, и есть только post – добавление заказа.
@RestController
@RequestMapping("/api/orders")
@CrossOrigin
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) {
this.orders = orders;
}
@PostMapping
public ResponseEntity<?> create(@RequestBody OrderRequest req) {
try {
OrderResponse resp = orders.create(req);
return ResponseEntity.ok(resp);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage()));
}
}
public record ApiError(String code, String message) {}
}
Базы у меня нет, и заказы я пока храню просто в коллекции в OrderStorage:
@Component
public class OrderStorage {
private final List<OrderRequest> orders = new ArrayList<>();
private final ProductService products;
public OrderStorage(ProductService products) {
this.products = products;
}
public synchronized void add(OrderRequest order) {
orders.add(order);
printAll();
}
private void printAll() {
//вывод всех заказов в консоль для тестов
}
}
Теперь можно перейти и к рефакторингу фронта.
Vue.js: работа с API
Fetch vs axios
Для работы с HTTP-запросами в проекте я буду использовать библиотеку axios. Fetch тоже умеет отправлять такие запросы, но он проигрывает axios по некоторым параметрам.
Во-первых, fetch не считает HTTP 4xx/5xx ошибками. Нам придётся прописывать каждый раз:
const res = await fetch('/api/products')
if (!res.ok) {
}
axios же сам выкидывает исключение в случае ошибки, и нам остаётся только его ловить:
try {
const { data } = await http.get('/products')
} catch (e) {
if (e.response?.status === 404) {
}
}
Во-вторых, fetch не умеет в автоматический парсинг json-объектов:
const res = await fetch(...)
const data = await res.json()
С axios достаточно
const { data } = await http.get(...)
Под капотом в axios происходят те же две асинхронные операции, но в итоге в большом проекте axios используется как более удобная и расширяемая обёртка над HTTP, позволяющая сосредоточиться на логике приложения, а не на деталях обработки запросов. Чуть меньше кода – меньше шансов ошибиться.
Установка и настройка axios
Чтобы можно было пользоваться этой библиотекой, устанавливаю его:
npm install axios
И тогда вижу в package.json зависимость:
"dependencies": {
"axios": "^1.13.2",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
}
Для работы с axios также создам файл api/http.js, в котором настрою axios под свой проект:
import axios from 'axios'
export const http = axios.create({
baseURL: '/api',
timeout: 10000
})
Тут я указываю таймаут и базовый адрес, чтобы не писать его в каждом запросе. У нас это одно слово, но может быть ситуация, где это довольно длинная ссылка, которая будет портить читаемость запросов со страниц.
Теперь всё готово, чтобы переписать страницы.
Рефакторинг каталога продуктов и страницы заказа
Начнём с get-запросов в CataloguePage.vue. Раньше я при создании vue доставал данные из файла в том же проекте и сохранял их в кэш в случайном порядке (решил, что так будет интереснее, чтобы элементы не показывались в том порядке, в котором мы добавили их в файл):
created() {
const saved = sessionStorage.getItem(STORAGE_KEY)
if (saved) {
try {
this.items = JSON.parse(saved)
return
} catch (e) {
console.warn('Failed to parse cached products, refetching...', e)
}
}
fetch('/data/products.json')
.then(response => response.json())
.then(data => {
const shuffled = this.shuffleArray(data)
this.items = shuffled
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(shuffled))
})
},
Теперь заменяем fetch на axios:
created() {
const saved = sessionStorage.getItem(STORAGE_KEY)
if (saved) {
try {
this.items = JSON.parse(saved)
return
} catch (e) {
console.warn('Failed to parse cached products, refetching...', e)
}
}
http.get('/products')
.then(({ data }) => {
const shuffled = this.shuffleArray(data)
this.items = shuffled
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(shuffled))
})
.catch(err => {
console.error(err)
})
}
И больше ничего менять не надо, так как ничего в данных у нас не изменилось – только их источник. Чтобы всё заработало, нужно подключить axios на эту страницу, а мы уже подготовили для этого http.js:
import { http } from '../api/http'
Именно поэтому я пишу http.get(‘/products), а не axios.get(‘/api/products’);
Теперь закрепим успех и перепишем страницу продукта в ProductPage.vue. Раньше на странице в разделе methods мы писали так:
async loadProduct(id) {
this.loading = true
this.error = ''
try {
const response = await fetch('/data/products.json')
const data = await response.json()
const found = data.find(item => item.id === id)
if (!found) {
this.$router.replace({ name: 'not-found' })
return
}
this.product = found || null
} catch (e) {
console.error(e)
this.error = 'Failed to load product.'
} finally {
this.loading = false
}
}
То есть мы доставали весь список, находили в нём нужный объект и присваивали его product из раздела data. Но теперь нам не нужно проходить через весь список, ведь у нас есть подходящий эндпойнт по id:
async loadProduct(id) {
this.loading = true
this.error = ''
try {
const { data } = await http.get(`/products/${id}`)
this.product = data
} catch (e) {
if (e?.response?.status === 404) {
this.$router.replace({ name: 'not-found' })
return
}
console.error(e)
this.error = 'Failed to load product.'
} finally {
this.loading = false
}
}
Основная логика осталась той же, мы просто поменяли источник. Напомню, откуда берётся id:
created() {
const id = Number(this.$route.params.id)
this.loadProduct(id)
}
Его мы берём из параметров в адресной строке.
И теперь осталась только страница оформления заказа. Раньше было так:
handleFormSubmit(formData) {
if (!this.localLines.length) {
return
}
const order = {
...formData,
items: this.localLines,
totals: {
itemsTotal: this.itemsTotal
},
createdAt: new Date().toISOString()
}
this.$emit('place-order', order)
}
И в Shop.vue мы перехватываем размещение заказа:
handlePlaceOrder(order) {
console.log('Order placed:', order)
this.cart = []
this.$router.push({ name: 'checkout-success' })
}
То есть раньше заказ был фиктивным. Мы вообще никуда его не сохраняли и забывали о нём, как только закрывалась страница. Писали о нём в логе, очищали корзину и на этом всё. Но теперь у нас есть хранилище в бэке, которое помнит о заказах хотя бы пока мы не остановили сервер.
Теперь заменяем этот метод:
async handlePlaceOrder(order) {
try {
const payload = {
customer: order.customer,
shipping: order.shipping,
comment: order.comment,
items: order.items.map(line => ({
productId: line.item.id,
amount: line.amount
}))
}
const { data } = await http.post('/orders', payload)
console.log('Order created:', data)
this.cart = []
this.$router.push({ name: 'checkout-success' })
} catch (e) {
console.error(e)
alert('Failed to place order.')
}
}
Впервые использовали метод post, но его использование не особо отличается от прошлого.
На этом мы закончили рефакторинг.
Что можно сделать дальше? Можно также реализовать на странице каталога фильтрацию по запросу в бэк. Так работают магазины с большим количеством товаров (поэтому после каждого применения фильтра, например, в амазоне или на букинге, мы видим некоторую задержку – там товары не кэшируются во фронте, как у нас).
И также можно сделать рефакторинг корзины – и тут всё будет не так уж и просто.
Extra: Рефакторинг корзины
Сейчас информация о корзине хранится во фронте, но неплохо было бы переместить её в бэк. Но тут проблема не только в том, что методов будет больше (и post для добавления, и get для просмотра, и даже delete для удаления из корзины), а в том, что корзина должна быть своя для каждого пользователя.
Для начала обсудим контракт api корзины. Данные будем передавать в таком формате (он соответствует тому, с чем уже работает фронт):
{
"lines": [
{
"item": {
"id": 23,
"artist": "Madonna",
"name": "True Blue",
"catNo": "MADONNA-TRUEBLUE",
"format": "vinyl",
"note": "classic reissue",
"img": "/img/Madonna_-_True_Blue_(album_cover).png",
"price": 24,
"description": "..."
},
"amount": 2,
"lineTotal": 48
}
],
"itemsTotal": 48
}
И эндпойты:
GET /api/cart – открыть корзину
POST /api/cart/items – добавить в корзину
body
CartLineRequest
PUT /api/cart/items/{productId} – обновить количество в корзине
params
productId – id товара
body
CartLineRequest (используется поле amount)
PUT /api/cart – заменить корзину целиком
body
CartLineRequest[]
DELETE /api/cart/items/{productId} – удаление записи из корзины
DELETE /api/cart – очистка всей корзины
Реализация сервиса тогда будет такой:
@Service
public class CartService {
private static final String CART_KEY = "CART_MAP"; // Map<Integer, Integer>
private final ProductService products;
public CartService(ProductService products) {
this.products = products;
}
public CartResponse getCart(HttpSession session) {
Map<Integer, Integer> map = getOrCreate(session);
return buildResponse(map);
}
public CartResponse add(HttpSession session, int productId, int amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be > 0");
Product p = products.findById(productId);
if (p == null) throw new IllegalArgumentException("Unknown productId=" + productId);
Map<Integer, Integer> map = getOrCreate(session);
map.put(productId, map.getOrDefault(productId, 0) + amount);
return buildResponse(map);
}
public CartResponse setAmount(HttpSession session, int productId, int amount) {
Product p = products.findById(productId);
if (p == null) throw new IllegalArgumentException("Unknown productId=" + productId);
Map<Integer, Integer> map = getOrCreate(session);
if (amount <= 0) map.remove(productId);
else map.put(productId, amount);
return buildResponse(map);
}
public CartResponse replace(HttpSession session, List<CartLineRequest> lines) {
Map<Integer, Integer> map = getOrCreate(session);
map.clear();
if (lines != null) {
for (CartLineRequest l : lines) {
if (l.getAmount() <= 0) continue;
Product p = products.findById(l.getProductId());
if (p == null) throw new IllegalArgumentException("Unknown productId=" + l.getProductId());
map.put(l.getProductId(), l.getAmount());
}
}
return buildResponse(map);
}
public CartResponse remove(HttpSession session, int productId) {
Map<Integer, Integer> map = getOrCreate(session);
map.remove(productId);
return buildResponse(map);
}
public void clear(HttpSession session) {
Map<Integer, Integer> map = getOrCreate(session);
map.clear();
}
@SuppressWarnings("unchecked")
private Map<Integer, Integer> getOrCreate(HttpSession session) {
Object obj = session.getAttribute(CART_KEY);
if (obj instanceof Map<?, ?>) {
return (Map<Integer, Integer>) obj;
}
Map<Integer, Integer> map = new LinkedHashMap<>();
session.setAttribute(CART_KEY, map);
return map;
}
private CartResponse buildResponse(Map<Integer, Integer> map) {
CartResponse resp = new CartResponse();
int total = 0;
for (Map.Entry<Integer, Integer> e : map.entrySet()) {
Product p = products.findById(e.getKey());
if (p == null) continue; // на всякий случай
int amount = e.getValue();
int lineTotal = p.getPrice() * amount;
CartResponse.Line line = new CartResponse.Line();
line.setItem(p);
line.setAmount(amount);
line.setLineTotal(lineTotal);
resp.getLines().add(line);
total += lineTotal;
}
resp.setItemsTotal(total);
return resp;
}
}
Мапа с корзиной хранится не в бэке в поле (в отличие от наших заказов), а в сессии, так как это данные временные. Можно хранить их в бэке, чтобы содержимое корзины закреплялось за каждым пользователем, но у нас пока нет логинов и пользователей. Поэтому мы просто привязываем данные к сессии – протестировать работу можно будет, просто открыв разные браузеры – корзины там будут разными.
И контроллер будет выглядеть так;
@RestController
@RequestMapping("/api/cart")
@CrossOrigin
public class CartController {
private final CartService cart;
public CartController(CartService cart) {
this.cart = cart;
}
@GetMapping
public CartResponse get(HttpSession session) {
return cart.getCart(session);
}
@PostMapping("/items")
public ResponseEntity<?> add(@RequestBody CartLineRequest req, HttpSession session) {
try {
CartResponse resp = cart.add(session, req.getProductId(), req.getAmount());
return ResponseEntity.ok(resp);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage()));
}
}
@PutMapping("/items/{productId}")
public ResponseEntity<?> setAmount(@PathVariable int productId,
@RequestBody CartLineRequest req,
HttpSession session) {
try {
CartResponse resp = cart.setAmount(session, productId, req.getAmount());
return ResponseEntity.ok(resp);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage()));
}
}
@PutMapping
public ResponseEntity<?> replace(@RequestBody List<CartLineRequest> lines, HttpSession session) {
try {
CartResponse resp = cart.replace(session, lines);
return ResponseEntity.ok(resp);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage()));
}
}
@DeleteMapping("/items/{productId}")
public CartResponse remove(@PathVariable int productId, HttpSession session) {
return cart.remove(session, productId);
}
@DeleteMapping
public ResponseEntity<Void> clear(HttpSession session) {
cart.clear(session);
return ResponseEntity.noContent().build();
}
public record ApiError(String code, String message) {}
}
Тогда во фронте необходимо переписать Shop.vue, но сначала создам файл /api/cart.js:
import { http } from './http'
export const cartApi = {
get() {
return http.get('/cart')
},
add(productId, amount = 1) {
return http.post('/cart/items', { productId, amount })
},
setAmount(productId, amount) {
return http.put(`/cart/items/${productId}`, { productId, amount })
},
replace(lines) {
return http.put('/cart', lines)
},
remove(productId) {
return http.delete(`/cart/items/${productId}`)
},
clear() {
return http.delete('/cart')
}
}
Этот файл инкапсулирует все HTTP-запросы к серверному Cart api и предоставляет простой интерфейс (getCart, addToCart, removeFromCart). Это позволяет компонентам Vue не зависеть от конкретных URL и HTTP-методов, а также упрощает дальнейшие изменения api.
Тогда в Shop.vue подключаем этот файл
import { cartApi } from '../api/cart'
И нужно переписать все методы работы с корзиной:
methods: {
currency(value) {
return formatCurrency(value)
},
async refreshCart() {
const { data } = await cartApi.get()
this.cartState = data
},
async addToCart(product) {
const { data } = await cartApi.add(product.id, 1)
this.cartState = data
},
async deleteFromCart(product) {
const { data } = await cartApi.remove(product.id)
this.cartState = data
},
async updateCart(lines) {
const payload = lines.map(l => ({
productId: l.item.id,
amount: l.amount
}))
const { data } = await cartApi.replace(payload)
this.cartState = data
},
isInCart(product) {
return this.cartObjects.some(l => l.item.id === product.id && l.amount > 0)
},
async handlePlaceOrder(order) {
try {
const payload = {
customer: order.customer,
shipping: order.shipping,
comment: order.comment,
items: order.items.map(line => ({
productId: line.item.id,
amount: line.amount
}))
}
const { data } = await http.post('/orders', payload)
console.log('Order created:', data)
await cartApi.clear()
await this.refreshCart()
this.$router.push({ name: 'checkout-success' })
} catch (e) {
console.error(e)
alert('Failed to place order.')
}
}
}
Нужно не забыть также отредактировать размещение заказа, чтобы очистка происходила через новый api.
Теперь точно всё с рефакторингом. Остался лишь один момент: я обещал рассказать о vite proxy.
Vite proxy: избавляемся от CORS
Ещё один способ справиться с проблемой CORS – это сделать vite proxy. Нужно создать в корне проекта файл vite.config.js, в которым мы сделаем вид, что бэк находится на том же порту, что и фронт.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/img': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
Так как в проекте я обращаюсь не только к api, но и к изображениям по переданным нам ссылкам (они лежат на бэке в ресурсах в папке static), то img я тоже добавил сюда.
Proxy и @CrossOrigin решают одну задачу, но proxy делает запросы same-origin и убирает CORS на уровне браузера; @CrossOrigin просто даёт явное разрешение кросс-доменных запросов сервером, и это полезно когда фронт реально расположен отдельно.
На этом всё. Можно считать проект законченным: у нас есть магазин пластинок, в котором можем просматривать товары, добавлять их в корзину и размещать заказы. В этой статье мы разделили бэк и фронт, добавили api для каталога товаров, заказов и корзины, которую реализовали с помощью http-сессий.
Конечно, можно расширять проект и дальше, добавив настоящую базу данных и сделав страницу логина (тогда корзину можно будет хранить в базе или откладывать товары для будущих покупок), но основные идеи работы с Vue.js я уже разобрал.