Продолжаю изучать 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 и страницу оформления заказа.