Проект на Vue.js. Часть 5. Связываем Vue.js и Spring Boot через REST API

В прошлых частях этой серии статей я занимался изучением 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 я уже разобрал.

Оставить комментарий