Проект на Vue.js. Часть 3. Рефакторинг с компонентами и стилями. $emit и props

Продолжаю изучать Vue.js с учебным проектом. Делаю витрину магазина с винилом, и в прошлых частях мы запустили пустой проект и наполнили его карточками товаров, а потом добавили корзину и события вроде фильтров и кнопок с действиями.

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

Подключение стилей из файлов

Начнём рефакторинг со стилей. Я насоздавал их уже кучу и хоть в статьях не описывал (потому что фокус не на css), тут проигнорировать их не могу. Чтобы в App.vue не было огромного блока <style> можно их все вынести в отдельный файл в папке assets. Там я создал style.css и импортировал в файле main.js

import './assets/style.css'

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

Vite понимает такой импорт и подгружает весь css из файла в тег <style> или создаёт тег <link>. При сборке он выносит его в один из нескольких css-файлов, которые подключаются к итоговому index.html.

Делаем мы это именно в main.js, так как это входная точка в приложение. Всё, что импортируем здесь, является частью всего приложения. То есть стили глобальные, и мы можем иметь доступ к ним из любого компонента, а не только из App.vue.

Если хочется разбить стили по компонентам, и к каждому из них подключать свой файл, то можно это делать в компоненте следующим образом:

<script>
import './ProductCard.css'
export default { ... }
</script>

Это сработает так, что файл стилей будет связан с конкретным компонентом, но потом Vite всё равно подгрузит этот стиль в общую кучу. Лучший выбор для локальных стилей со словом scoped:

<style scoped>
@import './MyLocalStyles.css';
</style>

Тогда Vue автоматически потом проконтролирует, чтобы такой стиль не утёк за пределы компонента. При сборке проставит для каждого HTML-элемента уникальный атрибут, и применит стили только к элементам из этого компонента.

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

Компоненты во Vue

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

Объявить компонент можно прямо в файле main.js:

const app = Vue.createApp(App)

app.component('MyButton', { /* ... */ })
app.component('SomethingElse', { /* ... */ })

app.mount('#app')

Такой способ хорош для маленьких демо или прототипов в одном файле. Но в реальном проекте почти всегда используют локальные компоненты в отдельных файлах. Тогда каждый файл отвечает за свою часть интерфейса, код проще искать и переиспользовать, а App.vue не превращается в хаотичную свалку всего подряд. Так что я создам компоненты в новой папке components.

В таких файлах может быть точно такая же структура, как в главном App.vue: сегменты <template>, <style> и <script>

Внутри компонента можно использовать всё, что мы уже использовали: data, computed, methods и lifecycle-хуки (created, mounted и др.).

Для начала я создам самый просто компонент с шапкой сайта в файле HeaderBar.vue:

<template>
    <header class="header">
        <div class="logo-wrap">
        <img
            class="logo-img"
            src="/src/assets/logo.jpg"
            alt="Winylka logo"
        >
        <div class="header-text">
            <h1 class="site-title">Winylka Records</h1>
            <p class="site-subtitle">Selected vinyl records</p>
        </div>
        </div>
    </header>
</template>

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

В скрипте App.vue нужно подключить этот компонент:

import Header from './components/HeaderBar.vue'

А перед data() в экспорте добавить блок с компонентами:

components: {
    Header
  },

И тогда мы можем использовать его на странице (нужно не забыть поставить слэш в конце, закрывая тег):

<Header />

Использовать его можно как любой другой элемент: добавлять директивы и события. Например,

<Header v-if=”true” />

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

Но в тех других компонентах у нас есть логика и элементы ввода, и так легко мы уже не отделаемся.

Передача данных в компонент и передача событий обратно с $emit

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

Создам компонент ProductCard.vue и перемещу в его секцию <template> весь код карточки (в оригинальном проекте тег <article>). Но так как компонент использует данные, которых в нём нет, нам нужно их как-то передать из App.vue – из родителя. И для этого подходит директива v-bind (или просто : в сокращённом виде).

Тогда карточка товара в гриде с циклом в App.vue будет выглядеть следующим образом:

 <div class="grid">
      <ProductCard
        v-for="item in filteredProducts"
        :key="item.id"
        :item="item"
        :cheap="cheap"
        :sale="sale"
        :is-in-cart="isInCart"
        @add="addToCart"
      />
 </div>

Для каждого item из filteredProducts мы создаём по элементу ProductCard (внутри которого лежит <article> с заголовком, изображением, ценой и кнопкой добавления в корзину).

@add говорит, что мы слушаем событие ‘add‘, и вызываем функцию addToCart, которая у нас уже определена в методах в родителе.

Все необходимые элементы из data и computed мы передаём свойствами (props) и можем использовать их в компоненте. Так как я сохранил названия переменных, то почти ничего в коде карточки в компоненте менять не нужно.

Только вызов функции теперь будет выглядеть немного по-другому: нужно использовать $emit – указывая, что компонент хочет наоборот связаться с родителем:

@click="$emit('add', item)"

В скобках пишем название события в одинарных кавычках, а потом уже необходимые для работы метода параметры. В родителе мы как раз слушаем в ожидании этого события ‘add’ для добавления в корзину.

Таким образом, общая идея разбивки по компонентам в том, чтобы общее состояние приложения оставить в родителе (у нас это список товаров и список товаров в корзине), а всё остальное перенести в отдельные файлы. Тогда данные мы передаём по иерархии сверху вниз через props, а события наоборот вызываем снизу вверх от элементов к родителям с помощью $emit.

Ещё один нюанс: чтобы все пропсы привязывались как надо, нужно экспортировать компонент. В export default кладут описание проекта, включая его имя (name), все пропсы с их типами (может быть Object, Number и даже Function), и описывают все эмитируемые события:

<script>
import { formatCurrency } from '../utils/formatters'

export default {
  name: 'ProductCard',
  props: {
    item: {
      type: Object,
      required: true
    },
    cheap: {
      type: Number,
      required: true
    },
    sale: {
      type: Number,
      required: true
    },
    isInCart: {
      type: Function,
      required: true
    }
  },
  emits: ['add'],
  methods: {
    currency(value) {
      return formatCurrency(value);
    }
  }
}
</script>

props описывают, какие данные компонент ожидает от родителя и какого они типа.

emits: [‘add’] – контракт в другую сторону: компонент объявляет, какие события он может выбрасывать.

Ну и вот код разметки компонента:

<template>
  <article class="product-card">
    <div class="image-wrap">
      <img
        class="product-image"
        :src="item.img"
        :title="item.name"
        :alt="item.name"
      >

      <button
        @click="$emit('add', item)"
        class="image-cart-button"
        :class="isInCart(item) ? 'added' : 'to-add'"
        type="button"
        title="Add to cart"
      >
        🛒
      </button>
    </div>

    <div class="product-body">
      <h3 class="product-title">
        {{ item.artist }} – {{ item.name }}
      </h3>

      <p class="product-note">{{ item.note }}</p>
    </div>

    <div class="product-bottom">
      <div class="product-price">{{ currency(item.price) }}</div>

      <div class="product-tags">
        <span v-if="item.price < sale" class="tag tag-sale">SALE!</span>
        <span v-else-if="item.price < cheap" class="tag tag-good">
          GOOD OFFER!
        </span>

        <span class="tag tag-format">{{ item.format }}</span>
      </div>
    </div>
  </article>
</template>

В такой же манере я вынес в отдельные компоненты запись в корзине (CartItem.vue) и саму корзину (Cart.vue). В этом примере я показываю, что родителем не обязательно должен быть App.vue, можно создавать и более сложную структуру. У меня App.vue вообще не использует CartItem, а получает всю информацию из него из Cart.

<template>
    <div class="cart-list-item"> 
        <span
            @click="$emit('delete', cartItem.item)"
            type="button"
            class="cart-delete-btn"
            title="Remove from cart"
        >
        🗑
        </span>

        <span class="cart-item-name">{{ cartItem.item.name }}</span>
        <span class="cart-item-qty">×{{ cartItem.amount }}</span>
        <span class="cart-item-price">
            {{ currency(cartItem.item.price * cartItem.amount) }}
        </span>
    </div>
</template>

<script>
import { formatCurrency } from '../utils/formatters'

export default {
  name: 'CartItem',
  props: {
    cartItem: {
      type: Object,
      required: true
    }
  },
  emits: ['delete'],
  methods: {
    currency(value) {
      return formatCurrency(value);
    }
  }
}
</script>

Итак, CartItem эмитирует удаление элемента. И в Cart мы подхватываем это событие и передаём дальше с помощью ключевого слова $event.

<template>
  <div class="cart-summary-wrap">
    <button
      @click="$emit('toggle')"
      class="cart-summary"
      :class="{ 'cart-open': displayCart }"
      type="button"
    >
      <span class="cart-summary-icon">🛒</span>
      <span
        class="cart-summary-count"
        :class="{ 'cart-count-open': displayCart }"
      >
        {{ currency(cartTotal) }}
      </span>
    </button>

    <transition name="cart-dropdown">
      <div v-if="displayCart" class="cart-list">
        <template v-if="cartObjects.length">
          <transition-group
            name="cart-item"
            tag="div"
            class="cart-list-inner"
            appear
          >
            <CartItem
              v-for="line in cartObjects"
              :key="line.item.id"
              :cartItem="line"
              @delete="$emit('delete', $event)"
            />
          </transition-group>
        </template>

        <transition name="empty-fade" appear>
          <div v-if="!cartObjects.length" class="cart-empty">
            <p class="cart-note">Корзина пуста</p>
          </div>
        </transition>
      </div>
    </transition>
  </div>
</template>

<script>
import { formatCurrency } from '../utils/formatters'
import CartItem from '../components/CartItem.vue'

export default {
  name: 'Cart',
  components: {
    CartItem
  },
  props: {
    displayCart: {
      type: Boolean,
      required: true
    },
    cartObjects: {
      type: Array,
      required: true
    },
    cartTotal: {
      type: Number,
      required: true
    }
  },
  emits: ['toggle', 'delete'],
  methods: {
    currency(value) {
      return formatCurrency(value);
    }
  }
}
</script>

Такая логика правильная в нашем случае. Передаём событие родителю (Cart) и уже оттуда решаем, что с ним делать (передать дальше его родителю App.vue). Но можно встретить и другой вариант такого же поведения, когда событие передают напрямую к родителю родителя:

this.$parent.$parent.$emit('delete', this.cartItem.item);

В Vue есть ключевые слова $parent и $root, которые позволяют такое поведение, но за него я бы наказывал. В этом случае нарушается инкапсуляция (CartItem знает о том, в каком месте в дереве он находится) и мы жёстко привязываем его к иерархии (если захотим сделать ещё рефакторинг и что-то изменить, то нужно будет менять подобные методы). Так что его я не рекомендую.


На этом этапе работа с компонентами завершена. Можно, конечно, ещё выделить фильтрацию по максимальной цене отдельно или поиск, который мы делали в прошлой статье. Но я решил ограничиться этим. App.vue уже получился намного компактнее:

<template>
  <div class="page">
    <Header />

    <div class="section-header">
      <h2 class="section-title">Catalogue</h2>

      <Cart 
        :cart-objects="cartObjects"
        :display-cart="displayCart"
        :cart-total="cartTotal"
        @delete="deleteFromCart"
        @toggle="displayCart = !displayCart"
      />
    </div>

    <div class="filter-bar">
      <div class="filter-left">
        <label for="max-price" class="filter-label">
          Max price:
          <span class="filter-value">{{ currency(max) }}</span>
        </label>

        <input
          id="max-price"
          v-model.number="max"
          type="range"
          min="0"
          max="200"
          class="filter-range"
        >
      </div>

      <div class="filter-right">
        <label class="search-label">
          Search:
          <input
            v-model="search"
            type="text"
            class="search-input"
            placeholder="Artist or album"
          >
        </label>

        <span class="result-badge">
          {{ filteredProducts.length }} records
        </span>
      </div>
    </div>

    <div class="grid">
      <ProductCard
        v-for="item in filteredProducts"
        :key="item.id"
        :item="item"
        :cheap="cheap"
        :sale="sale"
        :is-in-cart="isInCart"
        @add="addToCart"
      />
    </div>
  </div>
</template>

<script>
import Header from './components/HeaderBar.vue'
import ProductCard from './components/ProductCard.vue'
import Cart from './components/Cart.vue'

import { formatCurrency } from './utils/formatters'
export default {
  components: {
    Header,
    ProductCard,
    Cart
  },
  data() {
    return {
      max: 200,
      cheap: 25,
      sale: 20,
      cart: [],
      displayCart: false,
      items: [],
      search: ''
    }
  },
  created() {
    fetch("/data/products.json")
      .then(response => response.json())
      .then(data => {
        this.items = data;
      })
  },
  methods: {
    currency(value) {
      return formatCurrency(value);
    },
    addToCart(product) {
      this.cart.push(product);
    },
    deleteFromCart(product) {
      const index = this.cart.findIndex(item => item.id === product.id);
      if (index !== -1) {
        this.cart.splice(index, 1); 
      }
    },
    isInCart(product) {
      return this.cart.some(item => item.id === product.id);
    }
  },
  computed: {
    filteredProducts() {
      const q = this.search.trim().toLowerCase();

      return this.items.filter(item => {
        const matchesPrice = item.price < this.max;

        if (!q) {
          return matchesPrice;
        }

        const haystack = `${item.artist} ${item.name}`.toLowerCase();
        const matchesSearch = haystack.includes(q);

        return matchesPrice && matchesSearch;
      });
    },
    cartTotal() {
      return this.cart.reduce((inc, item) => Number(item.price) + inc, 0);
    },
    cartObjects() {
      const map = new Map();

      this.cart.forEach(product => {
        if (!map.has(product.id)) {
          map.set(product.id, {
            item: product,
            amount: 1
          });
        } else {
          map.get(product.id).amount++;
        }
      });

      return Array.from(map.values());
    }
  }
}
</script>

Он всё ещё содержит основную логику, которая относится к состоянию приложения (добавление и удаление элементов из корзины, фильтрация товаров, проверка содержимого корзины), но часть логики мы всё же перенесли в компоненты.


В этой части мы вынесли в отдельные файлы компоненты и стили, научились использовать код повторно, и получили намного более короткий и читаемый App.vue. Никакие новые функции мы пока не добавляли: у нас всё ещё есть витрина магазина с хэдером, фильтром по цене и поиском по названию, с корзиной и возможностью добавлять туда и удалять оттуда товары.

В следующей части разберёмся с навигацией по проекту (Router) и добавим несколько новых страниц, включая о магазине, страницу товара, страницу 404 и страницу оформления заказа.

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