Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 2

от автора

Первая часть тут

Продолжаем разработку нашего интернет магазина. В этой части будет:

  • нормальная загрузка картинок по статическим адресам
  • генерация хлебных крошек на клиенте
  • страница товара
  • шапка
  • рабочая кнопка купить с синхронизацией товаров между вкладками (и сессиями)

Как можно увидеть вытаскивать случайные картинки с Unsplash довольно медленная идея. Лучше заранее указать в товарах статические картинки (для демонстрации). Поэтому давайте получим нужны адреса картинок и положим их в проект.

Пишем "загрузчик"

Для этого напишем простой асинхронный загрузчик.

const products = require('../static/mock/products.json')  const got = require('got') const QS = require('querystring') const API_KEY = ''  const fs = require('fs') const { promisify } = require('util')  const writeFileAsync = promisify(fs.writeFile)  async function fetchApiImg (searchQuery) {   try {     const query = QS.stringify({ key: API_KEY, q: searchQuery, per_page: '3', image_type: 'photo' })     const resPr = got(`https://pixabay.com/api/?${query}`)     const json = await resPr.json()     if (json.hits && json.hits.length > 0 && json.hits[0].largeImageURL && json.hits[0].webformatURL) {       return {         imgXL: json.hits[0].largeImageURL,         imgL: json.hits[0].webformatURL       }     } else {       throw 'no image'     }   } catch (error) {     return {       imgXL: null,       imgL: null     }   } } async function getImagesUrls () {   const imagesUrl = []   await Promise.all(     products.map(async product => {       const productName = product.pName.split(' ')[0]       const imgUrls = await fetchApiImg(productName)       imagesUrl.push({ id: product.id, urls: imgUrls })     })   )   return imagesUrl } async function main () {   try {     const imagesUrls = await getImagesUrls()     await writeFileAsync('./static/mock/products-images.json', JSON.stringify(imagesUrls), { flag: 'w+' })   } catch (error) {     console.log(error)   } } main() 

API_KEY нужно поставить свой (получаем его от сервиса за 1 мин).
Что примечательно, так это то что без особой возни, на моем компьютере этот скрипт выполняется за 1 сек, а это 500 асинхронных запросов (не большой такой ddos).

Для тех кто не особо понимает как работает скрипт вот подробное объяснение:

Про скрипт

  await Promise.all(     products.map(async product => {       const productName = product.pName.split(' ')[0]       const imgUrl = await fetchApiImg(productName)       imagesUrl.push({ id: product.id, url: imgUrl })     })   )

На каждом элементе массива из товаров мы вызываем асинхронную функцию, которая получает url картинки (делая запрос), добавляет его в массив imagesUrl и возвращает (неявно) Promise. await Promise.all означает что мы ждём завершения всех промисов и двигаемся дальше.

product.pName.split(‘ ‘)[0] — получаем первое слово из названия товара
imagesUrl этот массив будет хранить id товара и url фотографии для него.

    const query = QS.stringify({ key: API_KEY, q: searchQuery, per_page: '3', image_type: 'photo' })     const resPr = got(`https://pixabay.com/api/?${query}`)     const json = await resPr.json()     if (json.hits && json.hits.length > 0 && json.hits[0].largeImageURL && json.hits[0].webformatURL) {       return {         imgXL: json.hits[0].largeImageURL,         imgL: json.hits[0].webformatURL       }     } else {       throw 'no image'     }

QS.stringify стандартная нодовская функция querystring для создания (и не только) параметров запроса (можно и руками конечно написать, но зачем?)
got(https://pixabay.com/api/?${query}) получаем промис для запроса
await resPr.json() исполняем его, а результат парсим как json

Правим Store для картинок

Теперь изменим сервер, чтобы он использовал эти картинки.

store/index.js

// function for Mock API const sleep = m => new Promise(r => setTimeout(r, m)) const categories = [   {     id: 'cats',     cTitle: 'Котики',     cName: 'Котики',     cSlug: 'cats',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?cat,cats',     products: []   },   {     id: 'dogs',     cTitle: 'Собачки',     cName: 'Собачки',     cSlug: 'dogs',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?dog,dogs',     products: []   },   {     id: 'wolfs',     cTitle: 'Волчки',     cName: 'Волчки',     cSlug: 'wolfs',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?wolf',     products: []   },   {     id: 'bulls',     cTitle: 'Бычки',     cName: 'Бычки',     cSlug: 'bulls',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?bull',     products: []   } ] function addProductsToCategory (products, productsImages, category) {   const categoryInner = { ...category, products: [] }   products.map(p => {     if (p.category_id === category.id) {       categoryInner.products.push({         id: p.id,         pName: p.pName,         pSlug: p.pSlug,         pPrice: p.pPrice,         image: productsImages.find(img => img.id === p.id).urls       })     }   })   return categoryInner } export const state = () => ({   categoriesList: [],   currentCategory: {},   currentProduct: {} }) export const mutations = {   SET_CATEGORIES_LIST (state, categories) {     state.categoriesList = categories   },   SET_CURRENT_CATEGORY (state, category) {     state.currentCategory = category   },   SET_CURRENT_PRODUCT (state, product) {     state.currentProduct = product   } } export const actions = {   async getCategoriesList ({ commit }) {     try {       await sleep(1000)       await commit('SET_CATEGORIES_LIST', categories)     } catch (err) {       console.log(err)       throw new Error('Внутреняя ошибка сервера, сообщите администратору')     }   },   async getCurrentCategory ({ commit }, { route }) {     await sleep(1000)     const category = categories.find((cat) => cat.cSlug === route.params.CategorySlug)      const [products, productsImages] = await Promise.all(       [         await this.$axios.$get('/mock/products.json'),         await this.$axios.$get('/mock/products-images.json')       ]     )      await commit('SET_CURRENT_CATEGORY', addProductsToCategory(products, productsImages, category))   } } 

Тут примечателен только этот кусок:

    const [products, productsImages] = await Promise.all(       [         await this.$axios.$get('/mock/products.json'),         await this.$axios.$get('/mock/products-images.json')       ]     )

Так обычно батчатся асинхронные запросы к api. Можно хоть 20 штук так сделать и они будут загружаться параллельно.

И соответствующие Vue компоненты чуток подправить (не буду тут писать).

Получим в итоге что-то вроде:

Давайте добавим шапку

Создаём компонент

Header.vue

<template>   <div :class="$style.wrapper">     <div :class="$style.header">       <n-link :class="$style.logo" to="/">         <p>           Хвостики         </p>       </n-link>     </div>   </div> </template>  <script> export default {  } </script>  <style lang="scss" module> .wrapper {     background-color: $basic-bg-color;     height: 70px; } .header {   @include globalWrapper;   display: flex; } .logo {   font-size: 2.2em;   font-weight: 700;   opacity: 1;    color: #000;    text-decoration: none; } </style> 

И добавляем его в default.vue

default.vue

<Header />

<nuxt />

Наверное вы заметили что пропал стиль для mainWrapper .
Это не спроста, оборачивая весь шаблон в некий max-width мы лишаем себя гибкости. Как вариант можно создать миксин в файле global-variables.scss.
Вида

@mixin globalWrapper {   max-width: 1280px;   margin: 0 auto;   padding:  0 20px; } $basic-bg-color: #fcc000;

Дальше можно в нужном месте делать одинаковые отступы по сайту. Например в компоненте Header

.header {   @include globalWrapper;   display: flex; }

Во всех компонентах нужно включить этот миксин в необходимых местах.

Получаем такую шапку на десктопе:

И на мобильном:

Создаём страницу с товаром

По аналогии со страницей категории создаём _ProductSlug.vue

_ProductSlug.vue

<template>   <div :class="$style.page">     <div :class="$style.topBlock">       <div :class="$style.topLeftBlock">         <a :href="product.images.imgXL" target="_blank">           <img             v-lazy="product.images.imgL"             :class="$style.image"           />         </a>       </div>       <div :class="$style.topRightBlock">         <h1>{{ product.pName }}</h1>         <p>Цена: {{ product.pPrice }}</p>       </div>     </div>     <h2>Описание</h2>     <p>{{ product.pDesc }}</p>   </div> </template>  <script> import { mapState } from 'vuex' export default {   async asyncData ({ app, params, route, error }) {     try {       await app.store.dispatch('getCurrentProduct', { route })     } catch (err) {       console.log(err)       return error({         statusCode: 404,         message: 'Товар не найдена или сервер не доступен'       })     }   },   computed: {     ...mapState({       product: 'currentProduct'     })   },   head () {     return {       title: this.product.pTitle,       meta: [         {           hid: 'description',           name: 'description',           content: this.product.pMetaDescription         }       ]     }   } } </script> <style lang="scss" module> .page {   @include globalWrapper; } .image {   width: 400px;   height: auto; } .topBlock {   padding-top: 2em;   display: flex;   .topLeftBlock {     display: flex;   }   .topRightBlock {     padding-left: 2em;     display: flex;     flex-direction: column;   } } </style> 

Из интересного разве что, то что картинка кликабельная

        <a :href="product.images.imgXL" target="_blank">           <img             v-lazy="product.images.imgL"             :class="$style.image"           />         </a>

Где imgL среднее изображение, а imgL большое.

Получаем такой результат

Создаём хлебные крошки

Для начала немного философии. Так как nuxt не работает с meta информацией в роутах, а именно я говорю что например route объект для категории будет выглядеть так

{                                                                                                                                                                                                 name: 'category-CategorySlug',   meta: [     {}   ],   path: '/category/dogs',   hash: '',   query: {},   params: {     CategorySlug: 'dogs'   },   fullPath: '/category/dogs',   matched: [     {       path: '/category/:CategorySlug?',       regex: /^\/category(?:\/((?:[^\/]+?)))?(?:\/(?=$))?$/i,       components: [Object],       instances: {},       name: 'category-CategorySlug',       parent: undefined,       matchAs: undefined,       redirect: undefined,       beforeEnter: undefined,       meta: {},       props: {}     }   ] }

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

Есть много способов как реализовать их, например можно получать с сервера уже готовые крошки, но для демонстрации мы будем использовать другой метод, а именно:

Приведём файл Vuex к такому виду:

index.js

// function for Mock API const sleep = m => new Promise(r => setTimeout(r, m)) const categories = [   {     id: 'cats',     cTitle: 'Котики',     cName: 'Котики',     cSlug: 'cats',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?cat,cats',     products: []   },   {     id: 'dogs',     cTitle: 'Собачки',     cName: 'Собачки',     cSlug: 'dogs',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?dog,dogs',     products: []   },   {     id: 'wolfs',     cTitle: 'Волчки',     cName: 'Волчки',     cSlug: 'wolfs',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?wolf',     products: []   },   {     id: 'bulls',     cTitle: 'Бычки',     cName: 'Бычки',     cSlug: 'bulls',     cMetaDescription: 'Мета описание',     cDesc: 'Описание',     cImage: 'https://source.unsplash.com/300x300/?bull',     products: []   } ] function getProduct (products, productsImages, productSlug) {   const innerProduct = products.find(p => p.pSlug === productSlug)   if (!innerProduct) return null   return {     ...innerProduct,     images: productsImages.find(img => img.id === innerProduct.id).urls,     category: categories.find(cat => cat.id === innerProduct.category_id)   } } function addProductsToCategory (products, productsImages, category) {   const categoryInner = { ...category, products: [] }   products.map(p => {     if (p.category_id === category.id) {       categoryInner.products.push({         id: p.id,         pName: p.pName,         pSlug: p.pSlug,         pPrice: p.pPrice,         image: productsImages.find(img => img.id === p.id).urls       })     }   })   return categoryInner } function getBreadcrumbs (pageType, route, data) {   const crumbs = []   crumbs.push({     title: 'Главная',     url: '/'   })   switch (pageType) {     case 'category':       crumbs.push({         title: data.cName,         url: `/category/${data.cSlug}`       })       break     case 'product':       crumbs.push({         title: data.category.cName,         url: `/category/${data.category.cSlug}`       })       crumbs.push({         title: data.pName,         url: `/product/${data.pSlug}`       })        break      default:       break   }   return crumbs } export const state = () => ({   categoriesList: [],   currentCategory: {},   currentProduct: {},   bredcrumbs: [] }) export const mutations = {   SET_CATEGORIES_LIST (state, categories) {     state.categoriesList = categories   },   SET_CURRENT_CATEGORY (state, category) {     state.currentCategory = category   },   SET_CURRENT_PRODUCT (state, product) {     state.currentProduct = product   },   SET_BREADCRUMBS (state, crumbs) {     state.bredcrumbs = crumbs   },   RESET_BREADCRUMBS (state) {     state.bredcrumbs = []   } } export const actions = {   async setBreadcrumbs ({ commit }, data) {     await commit('SET_BREADCRUMBS', data)   },   async getCategoriesList ({ commit }) {     try {       await sleep(300)       await commit('SET_CATEGORIES_LIST', categories)     } catch (err) {       console.log(err)       throw new Error('Внутреняя ошибка сервера, сообщите администратору')     }   },   async getCurrentCategory ({ commit, dispatch }, { route }) {     await sleep(300)     const category = categories.find((cat) => cat.cSlug === route.params.CategorySlug)      const [products, productsImages] = await Promise.all(       [         await this.$axios.$get('/mock/products.json'),         await this.$axios.$get('/mock/products-images.json')       ]     )     const crubms = getBreadcrumbs('category', route, category)     await dispatch('setBreadcrumbs', crubms)      await commit('SET_CURRENT_CATEGORY', addProductsToCategory(products, productsImages, category))   },   async getCurrentProduct ({ commit, dispatch }, { route }) {     await sleep(300)     const productSlug = route.params.ProductSlug     const [products, productsImages] = await Promise.all(       [         await this.$axios.$get('/mock/products.json'),         await this.$axios.$get('/mock/products-images.json')       ]      )     const product = getProduct(products, productsImages, productSlug)     const crubms = getBreadcrumbs('product', route, product)     await dispatch('setBreadcrumbs', crubms)     await commit('SET_CURRENT_PRODUCT', product)   }  }

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

function getBreadcrumbs (pageType, route, data) {   const crumbs = []   crumbs.push({     title: 'Главная',     url: '/'   })   switch (pageType) {     case 'category':       crumbs.push({         title: data.cName,         url: `/category/${data.cSlug}`       })       break     case 'product':       crumbs.push({         title: data.category.cName,         url: `/category/${data.category.cSlug}`       })       crumbs.push({         title: data.pName,         url: `/product/${data.pSlug}`       })        break      default:       break   }   return crumbs }

Она работает таким образом: мы вызываем её 1 раз и передаём в неё текущий route, data (из которой мы будем вытягивать мета-информацию и pageType который мы передаём в зависимости от типа страницы с которой вызываем эту функцию.

В случае страницы товара мы добавляем и саму категорию в крошки и товар на который она указывает.

Соответственно сам товар должна хранить информацию о своих родителях (и о прародителях если этот товар лежит в категории 2-го уровня и тд.)

То есть в случае товара мы вызываем эту функцию ещё на этапе получения инфы от api

const crubms = getBreadcrumbs('product', route, product)

Что не самое оптимальное решение, так как нам нужно сначала получить запрос о товаре, а потом возможно придётся делать второй запрос к серверу. В данном случае этого не происходить так как мы генерируем крошки на клиенте.

В итоге мы в store получаем такую структуру

Создаём компонент хлебных крошек

Дальше это дело техники вывести эту информацию, создаём компонент Breadcrumbs.vue

Breadcrumbs.vue

<template>   <div v-if="bredcrumbs && bredcrumbs.length > 0" :class="$style.breadcrumbs">     <ul>       <li v-for="cr in bredcrumbs" :key="cr.url">         <n-link :to="cr.url">           {{ cr.title }}         </n-link>       </li>     </ul>   </div> </template>  <script> import { mapState } from 'vuex' export default {   computed: {     ...mapState({       bredcrumbs: 'bredcrumbs'     })   } } </script>  <style lang="scss" module> /* Style the list */ .breadcrumbs {   background-color: #eee;   padding: 10px 16px;   ul {     list-style: none;      @include globalWrapper;     li {       display: inline;       font-size: 18px;       + ::before {         padding: 8px;         color: black;         content: '/\00a0';       }       a {         color: #0275d8;         text-decoration: none;          &:hover {           color: #01447e;           text-decoration: underline;         }       }     }   } } </style> 

И добавляем его в наш layout, получаем такой результат:

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

Костыль

Создаём middlware

export default async function ({ store, route }) {   if (route.matched.length > 0) {     await Promise.all(route.matched.map(async ({ name }) => {       if (name === 'index') {         await store.commit('RESET_BREADCRUMBS')       }     }))   } }

И подключаем его в nuxt.config.js

  router: {     middleware: ['resetBreacrumbs'],     prefetchLinks: false   },

Это не самое лучшее решение, но как я уже выше написал — костыль.

Что это за магазин в котором ничего нельзя купить, срочно пишем корзину.

Корзина

Так как у нас нет api сервера, который будет хранить сессии мы будем хранить всё в localStorage
Можно конечно же сделать свою реализацию, так как Vuex предоставляет watcherы на изменения состояния. Мы можем на каждое изменение стейта заносить его в localStorage, но что если мы потом изменим структуру, а у пользователя будет неверная структура храниться, нужно будет или механизм миграции писать, или версионирования. Есть более просто решения, используем готовый модуль.

nuxt-vuex-localstorage

Добавим в проект nuxt-vuex-localstorage и в nuxt.config.js в модулях подключаем его

    ['nuxt-vuex-localstorage', {       ...(isDev && {         mode: 'debug'       }),       localStorage: ['cart'] //  If not entered, “localStorage” is the default value     }]

По умолчанию этот модуль не хитро шифрует localStorage на клиенте (может быть в целях безопасности, чтобы "сторонние" скрипты просто так не получили доступ к информации), поэтому для разработки отключаем это с помощью mode: ‘debug’. Также указываем наш модуль Vuex с которым этот плагин будет работать cart.

Создадим новый Vuex модуль

cart.js

// function for Mock API const sleep = m => new Promise(r => setTimeout(r, m))  export const state = () => ({   products: [],   version: '0.0.1'  }) export const mutations = {   ADD_PRODUCT (state, product) {     // if cart doesn't have product add it     if (!state.products.find(p => product.id === p.id)) {       state.products = [...state.products, product]     }   },   SET_PRODUCT (state, { productId, data }) {     state.products = [...state.products.filter(prod => prod.id !== productId), data]   },   REMOVE_PRODUCT (state, productId) {     state.products = Array.from(state.products.filter(prod => prod.id !== productId))   }  } export const actions = {   async addProduct ({ commit }, data) {     await sleep(300)     await commit('ADD_PRODUCT', data)   },   async removeProduct ({ commit }, productId) {     await sleep(300)     await commit('REMOVE_PRODUCT', productId)   }  } 

version: ‘0.0.1’ этот параметр стейта используется для решения конфликта версий текущего storage у пользователя и у нашего сайта.

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

Создаём кнопку купить

BuyButton.vue

<template>   <div v-if="product">     <button       v-if="!isProductAdded"       :class="$style.buy"       @click.prevent="buyClickHandler"     >       Купить     </button>     <a       v-else       :class="$style.added"       href="#"       @click.prevent="addedClickHandler"     >       Товар в корзине     </a>   </div> </template>  <script> import { mapActions, mapState } from 'vuex'  export default {   props: {     product: {       type: Object,       required: true     }   },   computed: {     ...mapState({       products: state => state.cart.products     }),     isProductAdded () {       return this.products.find(p => p.id === this.product.id)     }   },   methods: {     ...mapActions({       addProduct: 'cart/addProduct',       removeProduct: 'cart/removeProduct'     }),     buyClickHandler () {       this.addProduct(this.product)     },     addedClickHandler () {       this.removeProduct(this.product.id)     }   } } </script>  <style lang="scss" module> .buy {   background-color: $basic-bg-color; /* Green */   border: none;   color: #000;   padding: 15px 32px;   text-align: center;   text-decoration: none;   display: inline-block;   font-size: 16px;    &:hover {     cursor: pointer;   } } .added {   text-decoration: none;   border-bottom: 2px dotted; } </style> 

Здесь есть несколько интересных моментов.

  • в mapActions мы задаём путь указывая имя модуля cart/addProduct
  • в mapState делаем то же самое, но через анонимную функцию, которая получает объект state state => state.cart.products
  • храним флаг на случай если товар уже в корзине isProductAdded
  • если товара нет в корзине, то будет кнопка купить, если уже есть, то выводим ссылку при нажатии на которую товар удаляется из корзины.

Подключаем этот компонент в карточку товара и на страницу товара.

В итоге получается такое вот чудо

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

Итоги

  • Код проекта: на Github тыц.
  • Потыкать : тыц.

Это вторая часть моего незамысловатого примера использования Nuxt.
В следующей статье я хочу реализовать возможность изменять количество товара в корзине, удалять их, просматривать все товары используя модальные окна.

А также создать страницу оформления заказа, блоки с рекомендуемыми товарами и добавить микроразметку.

Послесловие

Ребята, я стараюсь делать быстро как могу, но на каждую статью уходит минимум 6 часов времени. Мне бы хотелось лучше понять насколько такой формат интересный.

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

Спасибо за чтение!

ссылка на оригинал статьи https://habr.com/ru/post/491018/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *