Админка магазина на vue.js. Урок 9. Перерабатываем фильтры

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

Мы уже поработали и с vuex, и с компонентами vue. А сегодня затронем обе темы и займемся небольшим рефакторингом. Возможно, вы спросите зачем?

Вообще я редко рассуждаю о правильных паттернах и практиках, но сегодня на меня напал легкий перфекционизм. Я уже немного бомбил на тему, что компоненты товаров и фильтры сделаны не так, как хотелось бы. Частично потому, что в первых уроках без vuex мои задумки неудобно было исправлять, частично потому что находились и другие интересные темы. В итоге товары и фильтры получились очень громоздким компонентом.

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

Итак, поехали.


Геттер getProductsByFilter

Для начала создадим в модуле products.js геттер для получения отфильтрованных и отсортированных товаров - getProductsByFilter. Код для геттера мы практически скопируем из компонента Products.vue, вычисляемого свойства filteredProducts. Но есть и отличия: до этого данные для фильтров и сортировок мы брали из свойств компонента Products.vue. Напомню, это selectCategory, selectBrand, minPrice и maxPrice, inputSearch и selectSort. Вроде ничего не забыл. Этих данных у нас в геттере нет, мы их передадим параметром. Это легально, ведь геттеры можно использовать не только как свойства, но и как функции. Все эти параметры мы завернем в объект filter и передадим его в геттер. А там уже разберем их по кусочкам и отфильтруем-отсортируем товары.

Вот пример объекта filter:

    {
        selectCategory: 2, 
        selectBrand: 5, 
        minPrice: 2500,
        maxPrice: 1000, 
        inputSearch: 'Asus',
        selectSort: 'price:desc'
    }

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

    getProductsByFilter: state => filter => {
        // Фильтруем товары
        let filtered = state.all
            // По категории
            .filter(product => {
                return filter.selectCategory == 0 || product.categoryId == filter.selectCategory;
            })

            // По брендам
            .filter(product => {
                return filter.selectBrand == 0 || product.brandId == filter.selectBrand;
            })

            // По ценам
            .filter(product => {
                return Number(product.price) >= filter.minPrice && Number(product.price) <= filter.maxPrice;
            })

            // По полю поиска
            .filter(product => {
                return filter.inputSearch == '' || product.title.toLowerCase().indexOf(filter.inputSearch.toLowerCase()) !== -1;
            });

        // Сортируем
        let sortKey = filter.selectSort.split(':')[0];
        let sortDir = filter.selectSort.split(':')[1];

        let sorted = _.sortBy(filtered, product => {
            return Number(product[sortKey]);
        });

        // При необходимости сортируем в обратном направлении
        if (sortDir === 'desc') {
            sorted = sorted.reverse();
        }

        return sorted;
    }

Первое, что видим, немножко необычная первая строка

    getProductsByFilter: state => filter => { ... }

В геттер всегда первым параметром передается state, а нам еще нужен второй параметр filter, поэтому получилась такая хитрая цепочка.

Второе, в самом начале в переменную filtered записываем не this.products, как раньше в Products.vue, а state.all. Ну логично, это же хранилище, там нет свойств, а есть state.

Третье, в условиях и вычислениях вместо this.название_поля пишем filter.название_поля. Помним, что эти данные берем не из свойств, а уже из входного параметра filter.

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

    let sortKey = filter.selectSort.split(':')[0];
    let sortDir = filter.selectSort.split(':')[1];

А так все, подробно алгоритм фильтрации и сортировки мы разбирали еще во втором уроке и принципиально там ничего не поменялось. Из компонентах геттер вызывается вот таким образом

    this.$store.getters['products/getProductsByFilter'](filter);

Но об этом чуть ниже.


Сокращаем код Products.vue в 3 раза

Да, в целых 3 раза: у меня было 154 строки, а осталось 54. Как это сделать?

Сначала выкинем весь html-код фильтров, вот такую здоровенную байду

    

    

    
    
    

    

    

    

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

    

Забегая чуть вперед: что за @filter="filter"? Это значит, что компонент ProductsFilters.vue будет генерировать событие filter (@filter), а при его срабатывании мы будем запускать метод filter компонента Products.vue. Метод тоже напишем ниже.

То есть вырисовывается такая схема: компонент фильтров при изменении любых данных (типа категории или сортировки) собирает все эти данные из 6 полей в один объект и отправляет его родительскому компоненту Products.vue. А тот уже вызывает геттер getProductsByFilter и отрисовывает таблицу.

В итоге получается так, что фильтры ничего не знают о товарах, они просто принимают от пользователя данные и отправляют их компоненту товаров. А товары в свою очередь не в курсе, как реализованы фильтры. Товары просто принимают набор фильтров, вызывают геттер и отрисовывают список товаров.

Теперь, разобравшись с общей схемой, можно дописать компонент Products.vue. Переходим к тегу script

Сначала убираем импорты mapGetters и lodash и импортируем компонент ProductsFilters. То есть в импортах будет так

    import ProductsFilters from './ProductsFilters'
    import ProductsItem from './ProductsItem'

Идем дальше. Регистрируем новый компонент ProductsFilters в поле components, вот так

    components: {
        ProductsFilters,
        ProductsItem
    }

Дальше изменения кардинальные. Удаляем из поля data все, что есть, все эти sortRules, selectBrand и minPrice. Нам это уже не интересно, пусть ими занимается новый компонент фильтров. А в товарах останется одно единственное поле - отфильтрованные товары filteredProducts

    data () {
        return {
            filteredProducts: []
        }
    }

Затем удаляем целиком поле computed. Да-да, minPriceAll, categories, products и filteredProducts, sortKey и sortDir здесь не нужны. Они или переходят в фильтры, или уже реализованы в геттере getProductsByFilter. Раздел watch тоже сносим, отслеживать изменения будем в фильтрах.

И еще изменим методы - поле methods. Сейчас у нас там очистка полей фильтров clear - это уходит тоже в фильтры. А вместо clear будет новый метод filter - тот самый, о котором я упоминал в начале, помните?

    

Это единственный метод, который действительно нужен в компоненте товаров и реализуется он очень просто

    methods: {
        filter (filter) {
            this.filteredProducts = this.$store.getters['products/getProductsByFilter'](filter);
        }
    }

В поле filteredProducts записывается результат вызова геттера. А набор параметров фильтра приходит из компонента ProductsFilters через прослушивание кастомного события @filter.

На этом с товарами все, компонент получился такой маленький, что я даже не постесняюсь привести полный код Products.vue

    
    
    
    
    

Как видите, он стал гораздо проще и понятнее. Фильтры и таблица. И один метод, принимающий набор фильтров и вызывающий геттер. Можно было еще и выделить таблицу в отдельный компонент, но и так получилось довольно коротко и аккуратно.

А теперь переходим к новому компоненту ProductsFilters


Компонент ProductsFilters

К сожалению, код, который мы весело выкинули из товаров, нельзя просто взять и удалить. Он должен всплыть, как, хм, поплавок, где-то в другом месте. В одном месте, в vuex-модуле products.js, всплыл геттер getProductsByFilter, а остальное мы оформим в компоненте ProductsFilters.

Для начала самое простое - шаблон. Здесь мы просто копируем html-разметку, которую удалили из товаров

    

С одним небольшим отличием: раньше в товарах поля типа selectCategory брались из соответствующих полей в data, а у нас они будут браться из объекта filter, например, filter.selectCategory

Теперь к javascript-коду. Сначала импортируем mapGetters

    import { mapGetters } from 'vuex'

Потом оформим data

    data () {
        return {
            sortRules: [
                { key :'id:asc', title: 'По порядку' },
                { key :'rating:desc', title: 'По рейтингу' },
                { key :'price:asc', title: 'По цене, сначала дешевые' },
                { key :'price:desc', title: 'По цене, сначала дорогие' }
            ],
            filter: {
                inputSearch: '',
                selectCategory: 0,
                selectBrand: 0,
                minPrice: 0,
                maxPrice: 0,
                selectSort: 'id:asc'
            }
        }
    }

sortRules мы уже видели и целиком скопировали, а в объект filter закинули все поля, участвующие в фильтрах и сортировках. Это тот самый объект, который передается в геттер getProductsByFilter. Ниже мы увидим, как он уходит наверх, в компонент товаров.

Теперь нам нужно создать несколько вычисляемых полей

    computed: {
        ...mapGetters('products', {
            minPriceAll: 'minPrice',
            maxPriceAll: 'maxPrice'
        }),
        categories () {
            return this.$store.state.categories.all;
        },
        brands () {
            return this.$store.state.brands.all;
        },
        products () {
            return this.$store.state.products.all;
        }
    }

minPriceAll и maxPriceAll мы получаем, как и в прошлом уроке, через mapGetters, а категории, бренды и товары через state.

Здесь вы можете задать коварный вопрос: как же так, ты давеча рассказывал, что фильтры ничего не знают о товарах? К сожалению, нам приходится здесь использовать товары для одной-единственной цели: отслеживать их изменения, чтобы устанавливать minPriceAll и maxPriceAll. Напомню, это минимальная и максимальная стоимости товаров и они задаются как начальные значения полей filter.minPrice и filter.maxPrice.

Возможно, есть вариант сделать это как-то элегантнее, через модуль products.js или еще как-то, но я пока не сообразил. Если у вас есть варианты, накидывайте в комментариях, буду благодарен любым идеям. Впрочем, и текущий код меня более менее устраивает, ведь это все равно намного лучше, чем мешанина фильтров и логики работы с товарами в одном компоненте.

Итак, насчет прослушивания изменений товаров, вот как это выглядит

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

Изменились товары, пересчитали максимальную и минимальную цены. В старом Products.vue мы это уже делали. А вот что новое, так это главная функция фильтров: передать данные наверх при изменении любого поля фильтров или сортировки. Реализуется это через прослушивание объекта filter

    filter: {
        handler (newFilter) {
            this.$emit('filter', newFilter);
        },
        deep: true
    }

Здесь по-другому: это не функция, как при прослушивании products в коде выше, а объект из двух полей. handler - это собственно функция, в которой событие уходит наверх вместе с данными через $emit. И deep: true - важный флажок, означающий, что нас интересует прослушивание изменений любого поля объекта filter. Без этого флага прослушивание работает для строк, чисел, массивов, но не объектов. Говорят, что в третьей версии vue Эван Ю это пофиксит, но пока так. Конечно, невелика сложность проставить deep: true, но не зная этой хитрости, можно убить порядочно времени, как это получилось у меня :-)

Мы заканчиваем писать компонент ProductsFilters, осталось реализовать метод clear - сброс фильтров в дефолтные значения

    methods: {
        clear () {
            this.filter = {
                inputSearch: '',
                selectCategory: 0,
                selectBrand: 0,
                minPrice: this.minPriceAll,
                maxPrice: this.maxPriceAll,
                selectSort: 'id:asc'
            }
        }
    }

Этот метод мы тоже уже видели, здесь только разница, что мы сбрасываем не 6 свойств, а 6 полей одного свойства filter.

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

Кстати, я не сторонник идеи, что нужно разбивать приложение на как можно меньшие компоненты, как советуют любители реакта. Если в компоненте 3 сотни строк, но они все относятся к одной сущности и легко считываются, то нет смысла плодить файлы и держать в уме их иерархию. Все-таки стараюсь придерживаться середины и не впадать в крайности.


Переносим компоненты в отдельную папку

Последнее, что мы сделаем сегодня - это чуток поправим организацию файлов в проекте. Сейчас в папке src лежит главный файл main.js, хранилище vuex - папка store и куча компонентов. Почему-то мы для хранилища выделили отдельную папку, а для компонентов нет. Давайте исправим, создадим папку components и перенесем в нее все компоненты. Только не забудьте поправить импорт App.vue в файле main.js

    import App from './components/App.vue'

Вот теперь в src все по полочкам: в components - компоненты, в store - хранилище vuex, в main.js - главный файл, запускающий приложение. Красота!

На сегодня все, удачи и до встречи в следующих уроках!

Предыдущие уроки админки на vue.js

Предыдущая статья Исходники
Метки:
Как Вам статья? Оцените!
Понравилась статья? Поделись с другими!
Подписка на новые статьи
Подписаться