Админка магазина на vue.js. Урок 14. Динамические роуты и карточка товара
Продолжаем разбираться с vue и строить нашу админку магазина. Сегодня мы будем заниматься динамическими роутами от vue-router, сделаем карточку товара и посмотрим, как сделать несуществующую страницу. Попутно глубже познакомимся с геттерами vuex, заведем еще больше конфигов в приложении и порассуждаем, зачем они нужны.
Погнали.
Динамический роутинг
В прошлом уроке мы научили приложение работать с роутами вида /categories и /brands и отображать по этим адресам нужные компоненты. Эти роуты были статическими, то есть не содержали никаких переменных. Если же мы захотим сделать карточку товара и каждому товару задать отдельный адрес, то без динамического роутинга не обойтись. Благо что во vue.js (впрочем, как и в других javascript-библиотеках) это делается очень просто.
Договоримся, что карточки товаров у нас будут открываться по адресам вида /products/{id}, где id - это id товара. Откроем файл router.js, где в переменной routes заданы все роуты приложения
let routes = [
{ name: 'products', path: '/', component: AppProducts},
{ name: 'categories', path: '/categories', component: AppCategories },
{ name: 'brands', path: '/brands', component: AppBrands}
];
Добавим еще одну строку
{ name: 'productsCard', path: '/products/:id', component: ProductsCard},
Аналогично с другими роутами задаем name, path и component. Отличие только в том, что в строке path появился :id. Двоеточием в роутах обозначается переменная, которая будет доступна в компоненте ProductsCard в объекте в объекте this.$route.params. Пока это все, что нужно знать о динамических роутах. Займемся реализацией и новым компонентом ProductsCard
Мы его еще не создали, но давайте сразу импортируем в router.js
import ProductsCard from './components/ProductsCard';
А потом уже создадим его в папке components. Это файл ProductsCard.vue такого содержания
Пока мы выводим только надпись "Товар номер id_товара". Все параметры, прокинутые из роута, доступны в компоненте в объекте this.$route.params. Соответственно, в this.$route.params.id и окажется наш айдишник. Для удобства заведем вычисляемое поле productId. Обратите внимание на знак +, мы приводим к числу id, потому что из роута параметры приходят в строковом виде.
Проверим. Запустим npm run dev, откроем в браузере страницу http://localhost:8080/#/products/4 и увидим такую картину
Попробуйте поменять айдишники и увидите, что он подхватывается правильно - заголовок меняется. Отлично.
Идем дальше. Добавим возможность открывать карточку из списка товаров. Для этого добавим в таблицу товаров кнопку-ссылку Открыть, чтобы получилось так
Нужно добавить новую колонку в таблице. Сначала идем в компонент AppProducts, где в блоке thead добавим еще один th в конце, пустой, вот так
А в компоненте ProductsItem выведем саму кнопку. Добавим новую ячейку
Дело привычное, router-link мы уже разбирали в прошлом уроке. В параметре :to значение linkOpen - это вычисляемое поле, которое тоже нужно прописать, уже в разделе script -> computed
computed: {
linkOpen () {
return `/products/${this.product.id}`;
}
}
Можно проверять, кнопки появились, ведут на нужные карточки и при перезагрузке мы оказываемся на той же странице карточки товара.
Пора ее усложнить и вместо айдишника вывести полную информацию.
Карточка товара, получаем полную информацию
Вспоминаем, что у нас есть vuex и хранилище store. И есть модуль products, в котором вся логика работы с товарами. В state.all хранится коллекция всех товаров. Напишем геттер, который по айдишнику товара вытащит нужный элемент из коллекции.
Файл store/modules/products.js
getProduct: state => id => {
return _.find(state.all, { id: id });
}
Вот так он выглядит, все по аналогии с уже написанными геттерами. Вернемся в компонент ProductsCard и добавим новое вычисляемое поле product, которое получит объект товара из этого геттера
product () {
return this.$store.getters['products/getProduct'](this.productId);
}
Давайте как-то выведем полученные данные в шаблоне, например, в теге pre
Увидим такую картину
Годится. Можно бы теперь заняться интерфейсом и раскидать нужные поля в таблицу, но давайте немного усложним задачу.
Усложняем геттер. Вызываем из геттера геттер из другого модуля
Вызываем геттер из геттера, кругом одни геттеры - давай по-русски, что мы будем делать? Прямо сейчас поле product содержит такие поля
id
title
categoryId
brandId
brand
price
rating
Обратите внимание, для брендов есть brandId и brand, а для категорий только categoryId. А хотелось бы иметь еще и название категории. Я уже не помню, почему мы не возвращаем категорию с бекенда, а только ее id, но примем это за данность. Конечно, можно поправить sql-запрос, но можно получить эту категорию и на клиенте. Ведь есть модуль categories в хранилище, который содержит все категории. И мы можем вытащить оттуда нужную категорию по айдишнику. А id категории есть в товаре. Цепочка сошлась, осталось собрать все в кучу.
Сначала сделаем геттер getCategory по аналогии с getProduct. Файл store/modules/categories
const getters = {
getCategory: state => id => {
return _.find(state.all, { id: id });
}
};
Теперь вернемся к геттеру getProduct. Пока он выглядит так
getProduct: state => id => {
return _.find(state.all, { id: id });
}
Но теперь нам нужно вернуть не просто объект товара, а расширить его названием категории. То есть получить товар, потом категорию, а потом склеить нужные данные в один объект. Вот так
getProduct: state => id => {
let product = _.find(state.all, { id: id });
let category = getCategory(product.categoryId); // пока что не сработает
return _.extend(product, {
category: category.title
});
}
Но тут есть одна хитрость. Мы ловко вызвали метод getCategory, но как vue поймет, откуда он взялся?
Раньше мы всегда передавали один параметр state в геттеры. Но оказывается, кроме state туда передаются еще 3 параметра:
getters - все геттеры текущего модуля
rootState - стейт родительского модуля, по сути state всех модулей
rootGetters - все геттеры родительского модуля
То есть если мы вместо
getProduct: state => id => { // код метода }
напишем
getProduct: (state, getters, rootState, rootGetters) => (id) => { // код метода }
то в коде метода мы получим доступ ко всем геттерам, а до геттера getCategory модуля categories можно достучаться так
rootGetters['categories/getCategory'](product.categoryId)
Поправим же код getProduct с учетом новых знаний
getProduct: (state, getters, rootState, rootGetters) => (id) => {
let product = _.find(state.all, { id: id });
let category = rootGetters['categories/getCategory'](product.categoryId);
return _.extend(product, {
category: category.title
});
}
Теперь зайдем на список товаров, откроем любую карточку и увидим, что в теге pre в конце добавилось поле category - то, что нужно.
Заходите в группу в контакте Webdevkin, там я пишу анансы статей, примеры кода и всякие разные штуки. Например, тему с геттерами я расписал за несколько дней до публикации этой статьи. То есть вы можете узнавать полезную инфу раньше, чем на сайте. К тому же в группе я пишу заметки чаще, чем посты на сайте. Надеюсь, они будут вам полезны :-)
Продолжим. С геттерами разобрались, но есть еще одна хитрость =) Если заходим в карточку со списка товаров, то у нас все хорошо. Но если мы зайдем в карточку и перезагрузимся, то увидим чистый лист, а в консоли страшную ошибку
Cannot read property 'categoryId' of undefined
Что не так? Дело в том, что когда мы открываем список товаров, они отрисовываются только когда все данные с бекенда получены. И когда мы открываем карточку, то в хранилище state.all уже загружены все товары. А когда мы открываем карточку напрямую, то мы не дожидаемся загрузки товаров и в геттере getProduct сразу лезем в state.all.
Вот и получается такая цепочка:
1. товаров в state.all нет
2. в переменную product попадает undefined
3. обращаемся к product.categoryId, а прочитать свойство невозможно
4. Cannot read property 'categoryId' of undefined
Можно каким-то образом дождаться загрузки товаров, а только потом запускать отрисовку шаблонов. А можно немного поправить код геттера, чтобы учесть, что коллекция товаров state.all может быть пустой. Однажды мы реализуем первый способ, но пока остановимся на втором
getProduct: (state, getters, rootState, rootGetters) => (id) => {
let product = _.find(state.all, { id: id });
if (product) {
let category = rootGetters['categories/getCategory'](product.categoryId);
return _.extend(product, {
category: category.title
});
} else {
return null;
}
}
Вот теперь все работает независимо от того, каким путем мы попадем на карточку товаров. Но что будет если мы попробуем в адресе вбить несуществующий айди товара? Прямо сейчас мы увидим пустой тег pre, а хотелось бы адекватного сообщения, что товар не найден. Давайте это реализуем
Товар не найден
Доработаем шаблон ProductsCard. Вместо простого вывода через pre напишем так
Конструкция v-if проверит, не пустой ли объект product и в противном случае выведет сообщение "Товар не найден". Если объект не пустой, то по старому - тег pre.
Даже если мы введем в урле не просто несуществующий айдишник, а бессмысленную строку вроде sdafklh, то приложение не поломается, а выведет правильный текст.
С данными закончили, пора облагородить нашу карточку товара и вывести поля в нормальном виде
Карточка товара, выводим нужные поля
Кажется, чего здесь делать-то? Есть объект со всеми полями, пишем, например, сверху название товара, в скобках артикул, а ниже в таблице перечисляем остальное: цена, бренд, категория и рейтинг. И выглядеть будет вот так
Открываем компонент ProductsCard и вместо
пишем такое
data-label - это не мой маразм, а требования фреймворка minicss.
Вроде все хорошо с таблицей, все выводится, но смущает одна вещь. Сейчас у нас всего 4 поля для вывода в таблице, а если добавится еще одно? А если пять? А еще если мы вообще реализуем возможность добавлять свойства руками, хоть сотню штук, то что тогда?
Предлагаю делать по-другому. Составим конфиг полей таблицы, в котором будут храниться названия характеристик товара и поля объекта, из которого мы будем доставать значения этих характеристик. Например, Цена - price или Категория - category. Тогда в первой колонке мы в цикле будем выводить названия, а во второй - значения полей объекта product. Как можно предсказать, код html-шаблона окажется красивее и лаконичнее.
У нас уже есть файл конфига для брендов в папке configs. Создадим рядом файл products.js
export default {
cardFields: [{
key: 'price',
title: 'Цена'
}, {
key: 'brand',
title: 'Бренд'
}, {
key: 'category',
title: 'Категория'
}, {
key: 'rating',
title: 'Рейтинг'
}]
}
Назовем поле-массив cardFields и заполним его объектами с нужными ключами и заголовками. И вернемся обратно в ProductsCard.
Чтобы использовать этот конфиг, сначала его импортируем в секции script
import config from '../configs/products';
Заметим, что просто вывести этот конфиг в таблице мы не можем. Для начала нужно расширить его значениями из объекта product. На базе конфига создадим вычисляемое поле, в который добавим еще и значения
fields () {
return config.cardFields.map(field => {
return {
key: field.key,
title: field.title,
value: this.product[field.key]
}
});
}
То есть это тот же конфиг-массив, только с новым полем value, которое мы получаем из product по ключу, указанному в конфиге. А вот теперь можно использовать вычисляемое поле в шаблоне. В теле таблицы tbody вместо такой портянки
напишем цикл v-for
Кажется, так стало намного лучше :-) Если у нас появится новое поле в товаре, то мы не будем копипастить html-строки таблицы, а просто добавим новый объект в конфиг. Любой шаблон vue выигрывает, когда код из него выносится в конфиги или вычисляемые поля. Чем меньше логики в шаблоне, тем лучше и легче читается разметка.
Есть и еще один плюс использования конфига - возможность его расширять. Представьте, что нам потребовалось, например, напротив каждой характеристики вывести подсказку с тултипом, который объясняет, что эта характеристика означает. А в тултипе будет не 2-3 слова, а намного больше. Даже на 4-х строках наш шаблон легко разрастется в 2 раза, где половину будут занимать бесполезные с точки зрения логики тексты. А с конфигом мы просто добавим в каждый объект новое поле tooltip, закинем туда тексты и в шаблоне выведем что-то вроде этого
В одном из следующих уроков мы рассмотрим детальнее, как подобные конфиги могут упростить жизнь. А сейчас осталось рассмотреть последнюю тему урока.
Несуществующая страница
В компоненте ProductsCard мы уже научились выводить сообщение "Товар не найден", если вбит неверный айдишник. Но что делать в более общем случае, когда неверно введен сам урл? Например, какой-нибудь /orders, которого пока еще не существует.
На обычных сайтах, где контент рендерится на сервере, применяют страницу 404. Она или возвращает 404 код, который обрабатывается браузером, или выводит текст "Страница не существует" или что-то подобное.
В одностраничном приложении нам нужен второй вариант. Мы должны уметь отлавливать несуществующие роуты и выводить компонент, назовем его AppNotFound. Сразу и создадим его
Просто одна строчка, но для примера достаточно. Теперь идем в router.js. Импортируем этот компонент
import AppNotFound from './components/AppNotFound';
И добавляем в конец массива routes такой объект
{ name: 'notFound', path: '*', component: AppNotFound}
То есть указываем path: '*', что означает - все урлы. Подвох в том, что этот path нужно добавлять именно в конец роутов, потому что роутер ищет нужное совпадение последовательно. И если совпадений не найдено, то роутер доберется до последней строки и подхватит компонент AppNotFound. Что нам и было нужно.
И на этом все. Скачивайте исходники, пробуйте, пишите код и любите vue :-)
Всем удачи и до встречи
Все уроки админки на vue.js
- Урок 1. Список товаров
- Урок 2. Фильтры и сортировки
- Урок 3. Новое REST API на чистом PHP
- Урок 4. Правим клиентский код под новое REST API и находим багу
- Урок 5. Разбиваем приложение на компоненты
- Урок 6. Инструмент vue-cli и vue-компоненты
- Урок 7. Flux и Vuex - общие вопросы
- Урок 8. Vuex на практике
- Урок 9. Перерабатываем фильтры
- Урок 10. Добавляем и удаляем бренды
- Урок 11. Обрабатываем ошибки на клиенте и сервере
- Урок 12. Редактируем бренды
- Урок 13. Роутинг
- Урок 14. Карточка товара
Истории из жизни айти и обсуждение кода.