Продолжаем работу с проектом на vue.js – я делаю магазин музыкальных пластинок, и в первой части мы создали пустой проект и потом вывели карточки товаров с ценами и изображениями. В этой части займёмся обработкой событий и фильтрацией данных.
В практике добавим к карточкам корзину – все товары можно будет добавлять и удалять оттуда.
Реактивность и работа с пользовательским вводом: v-model
Одно из самых главных преимуществ Vue – то, как он работает с пользовательским вводом. Для этого используется директива v-model.
v-model связывает значение поля формы с переменной в данных компонента (раздел data). Мы получаем двухстороннюю привязку: если в методах обновляем значение переменной, то она автоматически обновляется в интерфейсе, и наоборот, если пользователь меняет значение в поле ввода, то значение автоматически подставляется в модель данных.
V-model работает с <input>, <textarea>, <select>, радиокнопками, чекбоксами и со сложными кастомными компонентами.
Например:
<input v-model="max">
<p>Максимальная цена: {{ max }}</p>
И тогда, если в данных есть переменная max, и мы обновим её следующим образом:
this.max = 50
то значение в input поле и в тексте изменится на 50.
В textarea нельзя использовать выражения внутри, типа:
<textarea>{{ text }}</textarea>
Правильно:
<textarea v-model="text"></textarea>
Модификаторы v-model
Vue позволяет уточнять поведение с помощью модификаторов:
v-model.lazy обновляет значение после события change, а не на каждый ввод. Поле обновится, когда человек нажмёт Enter или уйдёт из поля.
<input v-model.lazy="name">
v-model.number автоматически приводит значение к числу:
<input v-model.number="max">
Без этого max был бы строкой «10», а с модификатором будет числом 10.
v-model.trim удаляет пробелы по краям:
<input v-model.trim="query">
Фильтрации товаров по цене и названию с v-model
Предлагаю дополнить наш проект ползунком, который позволит фильтровать список товаров по максимальной цене. Все товары, которые дешевле выбранной цены, должны оставаться на странице.
Для этого нужно наш существующий массив продуктов items отфильтровать. Создадим в разделе computed filteredProducts() и дополним данные переменной max, где будет храниться максимальное значение:
<script>
import { products } from './data/products.js'
export default {
data() {
return {
max: 200,
items: products
}
},
methods: {
currency(value) {
return `$${Number.parseFloat(value).toFixed(2)}`
}
},
computed: {
filteredProducts() {
return this.items.filter( item => (item.price < this.max))
}
}
}
</script>
Теперь можем добавить элемент, который будет отвечать за изменение значения max. Это может быть текстовое поле, но я предпочёл ползунок (type=”range”):
<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">
<span class="result-badge">
{{ filteredProducts.length }} records
</span>
</div>
</div>
В этом коде я вывожу максимальную цену, предлагаю выбрать новую и возвращаю длину отфильтрованного списка.
Тогда и в наших карточках нужно использовать computed-значение:
<article v-for="item in filteredProducts" :key="item.id" class="product-card">
Всё остальное в карточке остаётся так же, мы просто стали использовать не полный массив каждый раз, а его новую версию. Все обновления на странице будут происходить сами.
Теперь добавим поле для поиска по названию.
<label class="search-label">
Search:
<input
v-model="search"
type="text"
class="search-input"
placeholder="Artist or album"
>
</label>
Добавим переменную search, которая будет хранить запрос в данные:
search: ''
И теперь отредактируем filteredProducts() так, чтобы учитывалась не только максимальная цена:
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;
});
}
Если нет никакого запроса, мы фильтруем только по цене. Если он есть, то мы проверяем строку из названия и артистов на вхождение запроса.
Обработка событий во Vue: v-on и @click
Во Vue работа с событиями строится вокруг директивы v-on. Её задача – «слушать» события DOM (клики, ввод с клавиатуры, отправку формы и т.п.) и вызывать нужный код.
<button v-on:click="handleClick">
Кнопка
</button>
Сокращённая версия (так пишут почти всегда):
<button @click="handleClick">
Кнопка
</button>
click – название события (можно заменить на submit, keyup, change и др.)
handleClick – метод из methods компонента.
methods: {
handleClick() {
// сделать что-то при клике
}
}
Можно передавать аргументы:
<button @click="addToCart(item)">+</button>
methods: {
addToCart(product) {
this.cart.push(product)
}
}
Можно вызвать несколько методов, тогда они перечисляются через точку с запятой:
<button @click="addToCart(item); logClick(item)">+</button>
Модификаторы событий
Vue позволяет «подкрутить» стандартное поведение событий через модификаторы. Самые полезные:
.prevent отменяет поведение по умолчанию (например, отправку формы):
<form @submit.prevent="handleSubmit">
...
</form>
Без .prevent браузер отправит форму и перезагрузит страницу – для Vue-приложения это почти всегда лишнее.
.stop останавливает всплытие события выше по DOM, аналог stopPropagation() в js:
<button @click.stop="onInnerClick">Нажми меня</button>
Можно реагировать только на определённые клавиши: .ctrl, .alt, .shift, .meta (Command на Mac, Windows на Windows)
<input @keyup.enter="submitForm">
<input @keyup.esc="resetForm">
Можно совмещать события мыши и клавиатуры:
<button @click.ctrl="specialAction">
Ctrl + Click
</button>
.exact срабатывает только если ровно такой набор модификаторов:
<button @click.ctrl.exact="doSomething">
Только Ctrl + Click, без других клавиш
</button>
Можно реагировать на конкретную кнопку мыши с .left, .right или .middle.
<button @click.left="leftAction">
<button @click.right="rightAction">
<button @click.middle="middleAction">
Реализация корзины в проекте
Добавим в проект корзину. Для этого на каждую карточку нужно добавить кнопку добавления в корзину, в данные – переменные для хранения содержимого корзины (массив) и состояние корзины (показывается ли она на странице). Показывать её будет выпадающим списком.
Итак, в данные добавляем:
cart: [],
displayCart: false,
В методы добавим метод addToCart():
addToCart(product) {
this.cart.push(product);
},
А в computed свойствах добавим сумму цен продуктов из корзины:
cartTotal() {
return this.cart.reduce((inc, item) => Number(item.price) + inc, 0);
}
На каждую карточку товара добавим кнопку корзины с событием @click:
<div class="image-wrap">
<img class="product-image" :src="item.img" :title="item.name" :alt="item.name">
<button @click="addToCart(item)" class="image-cart-button" type="button" title="Add to cart">
🛒
</button>
</div>
А на верхней панели добавим элемент корзины:
<div class="cart-summary-wrap">
<button @click="displayCart = !displayCart" class="cart-summary" type="button">
<span class="cart-summary-icon">🛒</span>
<span class="cart-summary-count">{{currency(cartTotal)}}</span>
</button>
<div v-if="displayCart" class="cart-list">
<div v-for="item in cart" :key="item.id" class="cart-list-item">
<span class="cart-item-name">{{ item.name }}</span>
<span class="cart-item-price">{{ currency(item.price) }}</span>
</div>
</div>
</div>
</div>
Здесь мы отображаем корзину, только если displayCart true, и меняем значение переменной при каждом нажатии на элемент. Пока что в списке будем отображать все элементы в корзине и их цены.
Это очень базовый функционал корзины. Предлагаю его расширить: совмещать повторяющиеся элементы и выводить их количество, а также добавить возможность удалять элемент из корзины.
Для удаления элемента напишем ещё один метод:
deleteFromCart(product) {
const index = this.cart.findIndex(item => item.id === product.id);
if (index !== -1) {
this.cart.splice(index, 1);
}
}
Перед удалением мы здесь проверяем, есть ли вообще элемент в корзине (на всякий случай).
А если мы хотим выводить в корзине дополнительные данные, которых у нас изначально нет в разделе data, то нужно создать ещё одно compued-свойство.
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());
}
Здесь мы создаём мапу, чтобы исключить дублирование одинаковых объектов в корзине. Если пользователь добавил один и тот же товар несколько раз, то мы выведем его одной строкой. В качестве ключа используем id продукта, а внутри храним продукт и количество.
Тогда блок с корзиной будет выглядеть так:
<div v-if="displayCart" class="cart-list">
<template v-if="cartObjects.length">
<div
v-for="line in cartObjects"
:key="line.item.id"
class="cart-list-item"
>
<span
@click="deleteFromCart(line.item)"
type="button"
class="cart-delete-btn"
title="Remove from cart"
>
🗑
</span>
<span class="cart-item-name">{{ line.item.name }}</span>
<span class="cart-item-qty">×{{ line.amount }}</span>
<span class="cart-item-price">
{{ currency(line.item.price * line.amount) }}
</span>
</div>
</template>
<div v-else class="cart-empty">
<p class="cart-note">Корзина пуста</p>
</div>
</div>
Мы выводим данные уже из CartObjects, высчитываем общую стоимость каждого наименования исходя из количества и цены, и предлагаем удалить элемент из корзины.
Если корзина пустая, то выводим об этом сообщение, чтобы не показывался пустой элемент.
Можно ещё добавить подсветку кнопки корзины на элементе, если мы уже добавили товар:
<button @click="addToCart(item)" class="image-cart-button" :class="isInCart(item) ? 'added' : 'to-add'" type="button" title="Add to cart">
Я сделал это с помощью выбора класса, которой определил в стилях. IsInCart() проверяет, содержится ли объект в корзине:
isInCart(product) {
return this.cart.some(item => item.id === product.id);
}
На этот момент у нас уже очень неплохая корзина со всем основным функционалом. Перейдём к более продвинутым понятиям.
Жизненный цикл компонента: lifecycle hooks
Во Vue у компонента есть жизненный цикл от создания до удаления. Lifecycle hooks – это специальные методы, которые Vue вызывает на разных этапах этого цикла. В них можно «вклиниться» и сделать что-то в нужный момент.
Самые часто используемые хуки (Options API):
created() – данные уже инициализированы, но компонент ещё не в DOM
mounted() – компонент смонтирован, есть доступ к реальному DOM
updated() – сработал после обновления реактивных данных и DOM
unmounted() – компонент удалён, можно чистить таймеры, слушатели и т.п.
Хуки объявляются на том же уровне, где data, methods, computed:
export default {
data() {
return { products: [] }
},
created() {
// код при создании
},
mounted() {
// код после вставки в DOM
},
methods: {
...
}
}
У нас в проекте данные о продуктах пока что появляются из связанного js-файла, который лежит в src/data. Переместим данные в json файл products.json в папку public/data. И там же разместим изображения в папке img. Так как теперь у нас есть только json, и это не js-файл, то не нужно экспортировать данные:
[
{
"id": 20,
"artist": "ABBA",
"name": "Voyage",
"catNo": "ABBA-VOYAGE",
"format": "vinyl",
"note": "standard black vinyl",
"img": "/img/ABBA_-_Voyage.png",
"price": 28
}
]
И теперь мы можем на моменте created() подгружать эти данные с помощью fetch. В дальнейшем можно эту реализацию заменить и подгружать информацию, например, из API.
created() {
fetch("/data/products.json")
.then(response => response.json())
.then(data => {
this.items = data;
})
}
На этом предлагаю закончить. В этой части мы разобрались с реактивностью, добавили корзину и неплохо её проработали. В следующей части разобьём код на компоненты, чтобы он был более читабельным, потому что сейчас главный единственный файл App.vue сильно разросся.
2 Comments