Админка магазина на vue.js. Урок 8. Vuex на практике

апрель 9 , 2019
Метки:
Предыдущая статья Следующая статья Исходники

В прошлом уроке мы разобрали общую теорию работы с Vuex, пора реализовать это на практике. Одно дело почитать документацию, а другое - писать код под свои задачи. Как всегда, появляются какие-то тонкости, на которые при поверхностном изучении не обращаешь внимания, и приходится вникать по ходу работы.

Давайте договоримся, что будем делать в этом уроке. В прошлый раз я упоминал, что идея Vuex - это вынос общих данных в отдельное хранилище. У нас есть такие данные: категории, бренды и продукты. В этом уроке мы создадим общее хранилище и научимся работать с ним в приложении.

Теперь разберем все по отдельности и увидим, как преобразовывается код по мере внедрения Vuex.


Устанавливаем Vuex и выносим в него категории

Ставится Vuex как любой npm-пакет

    npm install --save vuex

Начинаем писать код хранилища. Для него создаем отдельную папку в проекте. В папке src заводим папку store, а в ней файл index.js и папку modules. В modules будем складывать отдельные модули: категории, бренды и продукты. Вообще можно все писать в одном файле, но со временем код будет только расти. Поэтому для нашего же удобства мы сразу разнесем хранилище на модули, так будет легче дальше жить.

Сначала разберем index.js

    import Vue from 'vue';
    import Vuex from 'vuex';
    
    import categories from './modules/categories';
    
    Vue.use(Vuex);
    
    export default new Vuex.Store({
        modules: {
            categories
        }
    });

Что здесь происходит? Импортируем Vue и Vuex, а также модуль категорий - его создадим чуть позже. Дальше мы даем команду Vue использовать Vuex и экспортируем экземпляр Vuex.Store - это и есть наше хранилище. Параметром modules мы передаем список используемых модулей, пока только категорий. Дальше в этом списке появятся бренды и продукты.

Теперь посмотрим на наш первый модуль categories.

Стандартная заготовка любого модуля выглядит так

    // import  - импортируем нужные библиотеки
    
    const state = {
        // Общие данные
    };
    
    const getters = {
        // Геттеры
    };
    
    const mutations = {
        // Мутации для изменения данных
    };
    
    const actions = {
        // Действия: ходим на сервер и вызываем мутации
    };
    
    // Экспортируем наружу
    export default {
        state,
        getters,
        mutations,
        actions
    }

State - это основа хранилища, те самые общие данные. Мутации нужны, чтобы изменять данные в state. Геттеры опциональны, в каких-то модулях мы их будем использовать, в каких-то нет. Насчет actions мы уже говорили в прошлом уроке, что они удобны, когда нужно отправить запрос на сервер и по его результатам изменить данные в хранилище.

Теперь подробнее по каждому пункту.

В начале файла мы импортируем библиотеку axios, через нее будем отправлять запросы на сервер.

    import axios from 'axios';

Разбираемся со state. Какие общие данные по категориям нам нужны? Да пожалуй, все категории нам и нужны - это одно поле, коллекция, по умолчанию пустая. Назовем ее all

    const state = {
        all: []
    };

Чтобы закинуть в all данные, нужно вызвать мутацию. Напишем ее вот так

    const mutations = {
        SET_CATEGORIES (state, categories) {
            state.all = categories;
        }
    };

Вот так просто. SET_CATEGORIES это просто функция, принимающая параметрами state и сами данные-категории. Называем мутацию капсом, так будет удобнее отделять мутации от действий.

Дальше вопрос: хорошо, мутация выглядит просто, но как запросить категории с сервера? А для этого создадим действие

    const actions = {
        getCategories (context) {
            axios
                .get('/admin/api/v1/categories')
                .then(response => {
                    context.commit('SET_CATEGORIES', response.data.records)
                });
        }
    };

Схема простая, действие getCategories делает get-запрос на сервер с помощью axios. В ответе response принимаем коллекцию категорий в response.data.records и вызываем мутацию с этими категориями.

Все, цепочка замыкается: чтобы получить категории, нужно вызывать действие getCategories, которое получит данные с сервера и вызовет мутацию SET_CATEGORIES. А мутация загонит данные уже в сам state в поле all.

Давайте посмотрим на весь модуль категорий

    import axios from 'axios';
    
    const state = {
        all: []
    };
    
    const getters = {};
    
    const mutations = {
        SET_CATEGORIES (state, categories) {
            state.all = categories;
        }
    };
    
    const actions = {
        getCategories (context) {
            axios
                .get('/admin/api/v1/categories')
                .then(response => {
                    context.commit('SET_CATEGORIES', response.data.records)
                });
        }
    };
    
    export default {
        namespaced: true,
        state,
        getters,
        mutations,
        actions
    }

В export default я незаметно добавил флаг namespaced: true. Эта опциональная, но полезная штука. Во Vuex почему-то сделано так, что когда мы используем модули, то для обращения к state из компонентов нам нужно указывать название модуля, например

    this.$store.state.categories.all; 

А вот для геттеров или действий этого не требуется

    this.$store.dispatch('getCategories');

Чтобы не путаться в названиях действий и не париться с уникальными именами во всем проекте, ввели понятие namespaced - этакое пространство имен. Чтобы не думать, getCategories - это получение всех категорий, как в нашем примере, или категории только для конкретного товара. Для нас namespaced означает, что при вызове геттеров или действий будем указывать так

    this.$store.dispatch('categories/getCategories');

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


Использование хранилища в приложении

Сначала идем в корневой файл приложения main.js. Там нужно добавить 2 строчки: импортировать store из нашей папки-хранилища store и передать его в экземляр new Vue. Полностью main.js будет такой

    import Vue from 'vue'
    import store from './store' // Добавили хранилище
    import App from './App.vue'
    import axios from 'axios'
    
    const debug = process.env.NODE_ENV !== 'production';
    
    // Для dev-режима
    if (debug) {
        axios.defaults.baseURL = 'http://w-shop.lc';
    }
    
    new Vue({
        el: '#app',
        store, // Передали хранилище в экземпляр приложения
        render: h => h(App)
    })

Это все, что нужно помнить про подключение хранилища. Больше в main.js мы не заглянем. Зато будем заглядывать в компоненты, в каждом из которых появилось свойство this.$store, через него мы и будем работать с Vuex.

Зайдем в компонент Products.vue. Именно в нем используются категории и отправляется запрос на сервер для их получения. Давайте изменим код с расчетом на Vuex.

Для начала вынесем categories из data(), теперь это будет вычисляемое поле, которое берет данные из хранилища

    computed: {
        categories () {
            return this.$store.state.categories.all;
        },
        // Остальные вычисляемые свойства
        filteredProducts () { ... },
        sortKey () { ... },
        sortDir () { ... }
    }

Все, теперь в селекте выбора категорий используется тот же categories, только данные в него приходят из store. Теперь нужно эти категории заполнить. Сейчас у нас в методе mounted в компоненте Products.vue вызывается такой код

    // Получение категорий
    axios
        .get('/admin/api/v1/categories')
        .then(response => {
            this.categories = response.data.records;
        });

Смело его удаляем, ведь для этого у нас уже есть действие getCategories. Нужно лишь правильно его вызвать. Вызывать из того же Products.vue нехорошо, потому что категории это глобальные данные. Логичнее будет получить их из компонента верхнего уровня App.vue.

Для этого создадим в App.vue метод created и вызовем нужное действие

    created () {
        this.$store.dispatch('categories/getCategories');
    }

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

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

Если вы еще не прониклись таким подходом, вспомните вот о чем. Когда-то мы создали компонент-заглушку Categories.vue, в которой вывели просто

    Категории

И даже сделали отдельный пункт в меню. Компонент-страница, конечно, подразумевает, что в нем нужны будут эти самые категории. Если работать по старой схеме, то нужно будет в App.vue или где-то еще создавать свойство categories, отправлять там запрос на сервер для их получения, а потом передавать во все нужные компоненты, например, в Products.vue и Categories.vue. И еще в кучу мест, где понадобятся категории в будущем.

А как будет выглядеть Categories.vue в нашем случае?

    
    
    

Вот, я написал простой шаблон и вычисляемое свойство, точно такое же, как в Products.vue. Нам не нужно задумываться о том, где объявлять "начальные категории" и через сколько компонентов пробрасывать соответствующее свойство. Мы просто берем их из глобального хранилища.

И это только преимущества чтения данных. А если же в каком-то компоненте из глубоких глубин приложения добавить новую категорию? Представьте, через сколько компонентов придется тащить через emit-ы эту новую категорию, для того чтобы обновить данные для всех. Вот эту проблему длинных цепочек прокидывания свойств и всплытия событий и решает Vuex-подход.

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


Выносим бренды в хранилище Vuex

С брендами будем работать точно так же, как и с категориями: создадим отдельный модуль в store, добавим вызов действия в App.vue для получения брендов с сервера, из Products.vue удалим ajax-запрос и свойство brands сделаем вычисляемым полем. Поехали.

Сначала модуль store/modules/brands.js

    import axios from 'axios';
    
    const state = {
        all: []
    };
    
    const getters = {};
    
    const mutations = {
        SET_BRANDS (state, brands) {
            state.all = brands;
        }
    };
    
    const actions = {
        getBrands (context) {
            axios
                .get('/admin/api/v1/brands')
                .then(response => {
                    context.commit('SET_BRANDS', response.data.records)
                });
        }
    };
    
    export default {
        namespaced: true,
        state,
        getters,
        mutations,
        actions
    }

Подробно разбирать его не будем, он ровно такой же, как и категории. Только не забудем подключить его в index.js в export default

    export default new Vuex.Store({
        modules: {
            categories,
            brands
        }
    });

Затем в App.vue в методе created добавим действие для получения брендов, чтобы получилось так

    created () {
        this.$store.dispatch('categories/getCategories');
        this.$store.dispatch('brands/getBrands');
    }

Дальше в Products.vue уберем поле brands из data() и добавим его в computed

    brands () {
        return this.$store.state.brands.all;
    }

И наконец из метода mounted удалим вызов axios.get

    // Получение брендов
    axios
        .get('/admin/api/v1/brands')
        .then(response => {
            this.brands = response.data.records;
        });

Все готово! Уже не так и сложно и запутанно, особенно после того, как разобрались с категориями.

Ну и по аналогии с категориями давайте изменим заглушку Brands.vue, чтобы убедиться, что хранилище работает как надо. Код Brands.vue

    
    
    

Вот так постепенно мы выносим общие данные в хранилище. Переходим к компоненту Products.vue и модулю products.


Работа с продуктами

Сперва идем по привычной схеме: создаем модуль store/modules/products.js

    import _ from 'lodash';
    import axios from 'axios';
    
    const state = {
        all: []
    };
    
    const getters = {};
    
    const mutations = {
        SET_PRODUCTS (state, products) {
            state.all = products;
        }
    };
    
    const actions = {
        getProducts (context) {
            axios
                .get('/admin/api/v1/products')
                .then(response => {
                    context.commit('SET_PRODUCTS', response.data.records)
                });
        }
    };
    
    export default {
        namespaced: true,
        state,
        getters,
        mutations,
        actions
    }

Очень похоже на categories и brands. Но есть одно отличие: здесь мы начнем использовать геттеры. Именно для этого первой же строкой мы импортировали lodash. Напишем 2 геттера: минимальная и максимальная цена продуктов

    const getters = {
        minPrice: (state) => {
            return state.all.length
                ? Number(_.minBy(state.all, 'price').price)
                : 0;
        },
        maxPrice: (state) => {
            return state.all.length
                ? Number(_.maxBy(state.all, 'price').price)
                : 0;
        }
    };

Эти геттеры будут заменой методов getMinPrice и getMaxPrice из компонента Products.vue. Как их использовать, чуть ниже по тексту, пока просто запомним, что они у нас есть.

И конечно, не забудем подключить новый модуль в index.js

    export default new Vuex.Store({
        modules: {
            categories,
            brands,
            products
        }
    });

Теперь быстренько заглянем в App.vue, чтобы в методе created привычно вызвать действие для получения всех продуктов, вот так

    created () {
        this.$store.dispatch('categories/getCategories');
        this.$store.dispatch('brands/getBrands');
        this.$store.dispatch('products/getProducts');
    }

А сейчас переходим в Products.vue - там работы будет немного больше.

Сперва удалим ajax-запрос из mounted и сам mounted тоже. Три get-запроса за категориями, брендами и продуктами, которые там были, мы вынесли в App.vue в три коротких строки dispatch - уже профит, короче и понятнее. Работаем дальше: products больше не свойство из data, удаляем его оттуда и добавляем в computed

    products () {
        return this.$store.state.products.all;
    }

Осталось разобраться с минимальной и максимальной ценой - не зря же мы выносили их в геттеры. Чтобы их использовать, пригодится удобная функция mapGetters, которую сначала нужно импортировать в разделе script

    import { mapGetters } from 'vuex'

Заодно уберите импорт axios - в компоненте он нам больше не понадобится, все ajax-запросы выполняются в хранилище.

Как работает mapGetters? Напишите в разделе computed вот такую штуку

    ...mapGetters('products', {
        minPriceAll: 'minPrice',
        maxPriceAll: 'maxPrice'
    })

Это означает, что в компоненте теперь будут доступны вычисляемые свойства minPriceAll и maxPriceAll, которые соответствуют геттерам minPrice и maxPrice из неймспейса products. Вот и все. То есть если у нас десяток геттеров, то мы просто перечислим все нужные в объекте-параметре mapGetters. Кстати, для стейтов, мутаций и действий есть аналогичные функции mapState, mapMutations и mapActions, но я их использовать не стал. И так нормально получилось.

Разбираемся дальше, вычисляемые поля minPriceAll и maxPriceAll мы получили. Теперь нужно удалить методы getMinPrice и getMaxPrice, которые мы раньше написали в Products.vue. Идем в раздел methods и удаляем оба метода.

Остался последний штрих. У нас есть поля в data: minPrice и maxPrice - это цены, которые задаются руками при фильтрации. Кроме обновления путем редактирования соответствующих инпутов есть 2 момента, когда мы их должны возвращать в первоначальное состояние. Первое - при получении товаров с сервера, раньше мы это делали в гет-запросе. А второе - когда жмем кнопку "Сбросить фильтры".

В обоих случаях нужно minPrice заменить на значение minPriceAll, а maxPrice - на minPriceAll, то есть так

    this.minPrice = this.minPriceAll;
    this.maxPrice = this.maxPriceAll;

Первый случай - обновление продуктов при получении их с сервера. Сейчас установкой значений продуктов занимается модуль в хранилище. Мы не можем и не хотим залезать в логику хранилища для обновления локальных компонентов. Но у нас в компоненте Products.vue есть вычисляемое свойство products, которое мы можем отслеживать. И при изменении его мы поймем, что продукты с сервера загрузились, а значит можно устанавливать минимальную и максимальную цену. Отслеживается изменение свойств через watch, добавьте это после объекта computed

    watch: {
        products () {
            this.minPrice = this.minPriceAll;
            this.maxPrice = this.maxPriceAll;
        }
    }

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

Теперь второй случай - пересчитывать цены при кнопке "Сбросить фильтры". Это проще, в методе clear нужно заменить старые функции на новые геттеры

    clear () {
        // Вот это удаляем                            
        // this.minPrice = this.getMinPrice();
        // this.maxPrice = this.getMaxPrice();
        
        // А вот это добавляем
        this.minPrice = this.minPriceAll;
        this.maxPrice = this.maxPriceAll;
    }

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

Компонент Products.vue и работа с фильтрами сделаны неудачно. Почему?

1. Products.vue просто огромный. Идея библиотек вроде реакта и vue в небольших компонентах, а не в монстрах, которые содержат в себе тонны логики
2. Фильтры должны быть выделены в отдельный компонент.
3. Фильтры не должны ничего знать о товарах, фильтры должны собирать данные для фильтрации и передавать их наверх
4. Компонент Products.vue должен делать всего 2 вещи: принимать данные по фильтрам и отображать список отфильтрованных товаров
5. Логику фильтрации товаров лучше вынести в хранилище.

Я специально не брался за эту задачу, потому что без использования Vuex это разделение хорошо усложнило бы код. Но сейчас самое время переработать страницу продуктов и вынести фильтры в отдельный компонент. Это не самая простая и довольно объемная часть кода, а урок и так получился нехилых размеров. Поэтому фильтры мы рассмотрим в следующей статье.

А пока скачивайте исходники урока, изучайте код и документацию Vuex - она очень хорошо и просто написана.

Удачи и до встречи!

Все уроки админки на vue.js

Предыдущая статья Следующая статья Исходники
Метки:
Заходите в группу в контакте - https://vk.com/webdevkin
Анонсы статей, обсуждения интернет-магазинов, vue, фронтенда, php, гита.
Истории из жизни айти и обсуждение кода.
Как Вам статья? Оцените!