Фильтры в интернет-магазине. Урок 6 и заключительный. Завершаем отделку

сентябрь 13 , 2016
Предыдущая статья Демо Исходники

Свершилось! Заключительный урок серии "Фильтры в интернет-магазине". В статье доработаем некоторые детали, чтобы придать каталогу завершенный вид. Мы узнаем, как обновлять список брендов и цены при смене категории, а также некоторые другие вещи. Начинаем.


Приводим в порядок категории

До сих пор мы не обращали внимания на список категорий, но пришла пора сделать его актуальным для нашего каталога. Можно рендерить этот список при отрисовке страницы, а можно выполнить отдельный ajax-запрос и отрисовать через underscore-шаблон. Если Вы разобрались с предыдующими примерами, то вывести актуальные категории из базы не составит никакого труда. Поэтому не будем перегружать статью очевидным и повторяющимся кодом и для примера этот список просто захардкодим. Естественно, в соответствии с таблицей categories в mysql.

Выглядеть наш список в catalog.html будет так:

    

А теперь займемся более интересными вещами.


Готовимся к получению разных данных от сервера

Чуть ниже в статье рассказывается о том, как загружать данные о брендах и ценах динамически. Эти данные будут передаваться в ajax-запросе вместе с товарами. Но здесь такой момент: нам не хочется нагружать сервер лишними sql-запросами при каждом обращении к нему. Например, если мы делаем сортировку или отмечаем бренды, то сам список брендов менять мы не будем. Незачем ходить за лишними данными, когда это не требуется. Поэтому сейчас мы подготовим наш код к тому, чтобы возвращать только нужные в конкретный момент данные.

В файле catalog.php есть функция getGoods, которая возвращает список товаров. Ее же мы и используем для возврата обратно клиенту в браузер. Добавим более универсальную функцию getData, которая будет возвращать в том числе и список товаров.

    // Получение всех данных
    function getData($options, $conn) {
        $result = array(
            'goods' => getGoods($options, $conn)
        );
    
        return $result;
    }

А теперь поправим содержимое блока try в коде

    // Подключаемся к базе данных
    $conn = connectDB();

    // Получаем данные от клиента
    $options = getOptions();

    // Получаем товары (было getGoods)
    $data = getData($options, $conn);

    // Возвращаем клиенту успешный ответ 
    // стало 'data' => $data вместо 'goods' => $goods)
    // также уберем строку options - завели ее для отладки, больше не понадобится
    echo json_encode(array(
        'code' => 'success',
        'data' => $data
    ));

Как видим, мы стали возвращать в getData ассоциативный массив, единственным ключом которого сейчас является список товаров. Пока особенного толка от этого нет, но зато таким нехитрым приемом мы легко сможем расширить ответ от сервера, возвращая какие угодно новые данные, не боясь поломать существующий функционал. Для этого мы всего лишь будем добавлять новые элементы в итоговый массив getData, чем и займемся в следующем пункте статьи.

И не забудем внести еще одну правку на клиенте. В файле catalogDB.js в методе _catalogSuccess вместо responce.goods напишем responce.data.goods - учтем только созданный на сервере дополнительный ключ массива data.

    ui.$goods.html(template({goods: responce.data.goods}));

Меняем бренды и цены при смене категории. Что нужно сделать?

В каждом ответе от сервера нам нужно обязательно получать массив товаров, ведь после каждого ajax-запроса перерендеривается каталог. Но опцинально хотим получать еще и список брендов, а также минимальную и максимальную цену. Причем не просто все бренды из базы, а только те, которые присущи выбранной категории. То же самое и с ценами. Если мы знаем, что для ноутбуков диапазон цен составляет от 10 до 100 тысяч, то незачем ставить ползунок от 500 рублей до миллиона. Аналогично бренд GIGABYTE стоит отображать только в категории Видеокарты, но не ноутбуки.

Как же это сделать? Сделаем просто - для каждого ajax-запроса будем передавать дополнительный параметр, в котором перечислим данные, нужные нам после выполнения запроса. Или не будем передавать ничего, если нам, как и раньше, нужен только список товаров. Сервер посмотрит на этот параметр, если он увидит, что клиент что-то хочет, то запустит дополнительные процедуры извлечения данных из базы и вернет их клиенту. Если нет, то просто по привычке вытащит список товаров.

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


Реализация дополнительных параметров на сервере

Давайте уже посмотрим на код. Начнем с серверной части. Договоримся, что мы вводим новый параметр needs_data, в котором тупо через запятую перечислены дополнительные требуемые данные. Например, needs_data='brands,prices' означает, что нам нужен от сервера список брендов и минимальная/максимальная цена. Конечно, для заданной категории. Обновим функцию получения данных getOptions. В самом ее начале к $categoryId, $minPrice и $maxPrice добавим $needsData и возвратим его в return array() в конце функции.

    $needsData = (isset($_GET['needs_data'])) ? explode(',', $_GET['needs_data']) : array();
    ...
    return array(
        'brands' => $brands,
        'category_id' => $categoryId,
        'min_price' => $minPrice,
        'max_price' => $maxPrice,
        'sort_by' => $sortBy,
        'sort_dir' => $sortDir,
        'needs_data' => $needsData
    );

Мы переводим строку в массив для удобства или заводим пустой массив, если needs_data не передан.

Теперь нужно дописать код getData, напомню, мы специльно его сделали в расчете на дополнительные данные. Выглядит теперь это так

    // Получение всех данных
    function getData($options, $conn) {
        $result = array(
            'goods' => getGoods($options, $conn)
        );
    
        $needsData = $options['needs_data'];
        if (empty($needsData)) return $result;
    
        if (in_array('brands', $needsData)) {
            $result['brands'] = getBrands($options['category_id'], $conn);
        }
        if (in_array('prices', $needsData)) {
            $result['prices'] = getPrices($options['category_id'], $conn);
        }
    
        return $result;
    }

Сначала в обязательном порядке записываем в $result товары через getGoods. А затем смотрим, если в массиве требуемых товаров есть бренды или цены, то запускаем функции получения соответствующих данных getBrands и getPrices и записываем их результаты в массив под ключами brands и prices. В эти функции передаем id категории и объект связи с базой данных $conn. Осталось написать реализацию этих двух функций. Приведу сразу код, потому что мы уже писали на предыдущих уроках запросы и посложнее.

    // Получаем бренды по категории
    function getBrands($categoryId, $conn) {
        if ($categoryId !== 0) {
            $query = "
                select
                    distinct b.id as id,
                    b.brand as brand
                from
                    brands as b,
                    goods as g
                where
                    g.category_id = $categoryId and
                    g.brand_id = b.id
            ";
        } else {
            $query = 'select id, brand from brands';
        }
        $data = $conn->query($query);
        return $data->fetch_all(MYSQLI_ASSOC);
    }
    
    // Получаем минимальную и максимальную цену
    function getPrices($categoryId, $conn) {
        $query = "
            select
                min(price) as min_price,
                max(price) as max_price
            from
                goods
        ";
        if ($categoryId !== 0) {
            $query .= " where category_id = $categoryId";
        }
        $data = $conn->query($query);
        return $data->fetch_assoc();
    }

В getBrands мы строим запрос в зависимости от того, выбрана категория или нет. Если не выбрана, то запрос предельно упрощается, если категория указана, то навешивается пара условий where.

В getPrices запрос тоже нехитрый. Выбираем минимальную и максимальную цены, а если указана категория, то добавляем в строку запроса соответствующее условие. Обратите внимание, что цены возвращаются в виде массива array('min_price' => X, 'max_price' => Y).

С сервером закончили, теперь посмотрим, как использовать новые возможности на клиенте.


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

Вспомним, что запрос на сервер инициируем функция _getData в catalogDB.js. При этом при инициализации приложения и при смене каталога нам требуется новый список брендов и цен, подходящий только под выбранную категорию. Добавим в функцию _getData необязательный параметр options, в котором будем перечислять нужные нам данные. Новые параметры нужны только в функции _init и _changeCategory. Изменим в этих функциях по одной строке - добавим в _getData параметр {needsData: 'brands,prices'}. Передавать объектом одну строку не обязательно, но в плане возможного расширения функционала это удобнее. Мы уже убеждались в этом на серверной стороне, так почему бы не использовать этот опыт на клиентской :-)

    // Инициализация модуля
    function init() {
        _initPrices({
            minPrice: 5000,
            maxPrice: 50000
        });
        _bindHandlers();
        _getData({needsData: 'categories,brands,prices'});
    }
    
    // Смена категории
    function _changeCategory() {
        var $this = $(this);
        ui.$categoryBtn.removeClass('active');
        $this.addClass('active');
        selectedCategory = $this.attr('data-category');
        _getData({needsData: 'brands,prices'});
    }

Теперь нужно еще научить отправлять на сервер эти данные. Добавляем в _getData параметр options, а в само тело функции следующие строки

    if (options && options.needsData) {
        catalogData += '&needs_data=' + options.needsData;
    }

То есть приплюсуем к данным параметр needs_data. Напомню, что needs_data у нас не обязателен, клиент передает его далеко не всегда. Поэтому и необходимы лишние проверки на существование options.needsData. Полностью код функции выглядит так:

    // Получение данных
    function _getData(options) {
        var catalogData = 'category=' + selectedCategory + '&' + ui.$form.serialize();
        if (options && options.needsData) {
            catalogData += '&needs_data=' + options.needsData;
        }
        $.ajax({
            url: 'scripts/catalog.php',
            data: catalogData,
            type: 'GET',
            cache: false,
            dataType: 'json',
            error: _catalogError,
            success: function(responce) {
                if (responce.code === 'success') {
                    _catalogSuccess(responce);
                } else {
                    _catalogError(responce);
                }
            }
        });
    }

Сбор и отправку данных мы наладили. Для проверки можете открыть консоль и убедиться, что в ответе от сервера теперь приходят не только товары.

Можно переходить к самому интересному - отображению полученных данных.


Отображаем на клиенте полученные данные

Идея здесь нехитрая - получаем данные с сервера, смотрим, есть ли бренды и цены, отображаем то, что нужно. Для брендов нужно перерисовать содержимое #brands, для цен - обновить ползунок jqueryUI.slider. Для брендов заведем отдельный шаблон underscore в файле catalog.html. Назовем его brands-template и посмотрим на код.

    

Переключаемся на catalogDB.js. У нас был один шаблон для товаров goodsTemplate, давайте заведем еще и для брендов. Добавим в объект ui новое поле $brandsTemplate: $('#brands-template'). Чуть ниже переименуем глобальную переменную template в goodsTemplate и добавим еще одну brandsTemplate. То есть получится это.

    var selectedCategory = 0,
        goodsTemplate = _.template(ui.$goodsTemplate.html()),
        brandsTemplate = _.template(ui.$brandsTemplate.html());

И не забудем в связи с переименованием "товарного" шаблона поправить его название в _catalogSuccess: template -> goodsTemplate

    ui.$goods.html(goodsTemplate({goods: responce.data.goods}));

После баловства с названиями шаблонов уберем из объекта ui поле $brandInput: $('#brands input') - оно нам больше не понадобится. И вот почему. Раньше мы в функции привязки событий _bindHandlers писали

    ui.$brandInput.on('change', _getData);

А сейчас заменим эту строку на

    ui.$brands.on('change', 'input', _getData);

В чем отличие? Только в том, что первый вариант хорошо работал, когда список брендов у нас был захардкожен. Сейчас же мы собираемся менять список динамически, поэтому нельзя навешивать события на конкретные чекбоксы. Нужно навесить события на все чекбоксы, находящиеся внутри контейнера #brands. Чем и занимается строка из второго варианта. Будьте всегда внимательны в подобных вещах: удалите или перезапишете элемент - нужно заново навешивать события. Чтобы этого избежать, навешивайте их на общий контейнер, а не на конкретные элементы.

Теперь подготовим функцию, сбрасывающие фильтры в каталоге. Интересуют именно бренды и цены

    // Сброс фильтров, только брендов и цен
    function _resetFilters() {
        ui.$brands.find('input').removeAttr('checked');
        ui.$minPrice.val(0);
        ui.$maxPrice.val(1000000);
    }

Эта функция нам пригодится при смене категории, добавим вызов _resetFilters в _changeCategory

    // Смена категории
    function _changeCategory() {
        var $this = $(this);
        ui.$categoryBtn.removeClass('active');
        $this.addClass('active');
        selectedCategory = $this.attr('data-category');
        // Добавлена новая строка
        _resetFilters();
        _getData({needsData: 'brands,prices'});
    }

Также добавим функцию _updatePricesUI, которая меняет цены

    // Обновление цен
    function _updatePricesUI(options) {
        ui.$pricesLabel.html(options.minPrice + ' - ' + options.maxPrice + ' руб.');
        ui.$minPrice.val(options.minPrice);
        ui.$maxPrice.val(options.maxPrice);
    }

Применяется она уже в трех местах: сейчас в _onSlidePrices и _initPrices. Позже увидим третье место - там требуется отдельное пояснение. Вот код первых двух.

    // Изменение диапазона цен, реакция на событие слайдера
    function _onSlidePrices(event, elem) {
        _updatePricesUI({
            minPrice: elem.values[0],
            maxPrice: elem.values[1]
        });
    }
    
    // Инициализация цен с помощью jqueryUI
    function _initPrices(options) {
        ui.$prices.slider({
            range: true,
            min: options.minPrice,
            max: options.maxPrice,
            values: [options.minPrice, options.maxPrice],
            slide: _onSlidePrices,
            change: _getData
        });
        _updatePricesUI(options);
    }

Подготовительные процедуры пройдены, теперь нужно дописать код в _catalogSuccess. Проверяем, что приходит от сервера и запускаем соотвествующие процедуры: для брендов перерисовываем шаблон, для цен запускаем обновление слайдера.

    // Успешное получение данных
    function _catalogSuccess(responce) {
        ui.$goods.html(goodsTemplate({goods: responce.data.goods}));
        if (responce.data.brands) {
            ui.$brands.html(brandsTemplate({brands: responce.data.brands}));
        }
        if (responce.data.prices) {
            _initPrices({
                minPrice: +responce.data.prices.min_price,
                maxPrice: +responce.data.prices.max_price
            });
        }
    }

Почти все, осталось добавить маленькую деталь. Вы можете заметить, что при первоначальной загрузке страницы и смене категории выполняется целых 3 запроса к серверу, что не есть хорошо. Это связано с тем, что после первого запроса меняются ползунки на слайдере, когда ставятся цены, пришедшие с сервера. Это хорошо, то, что нам нужно, но при этом вызывается событие change слайдера, которое триггерит новые запросы к серверу, как будто цену сменил пользователь. Да еще и триггерит 2 раза, на каждый ползунок по одному разу. Это нехорошее поведение, которое нужно поправить. Нам надо различать, когда цены меняет пользователь руками, двигая ползунки, а когда js-код устанавливает цены с сервера. При этом событие change у слайдера нужно отключать. Навскидку можно предложить такой вариант: напишем функцию, которая инициализирует слайдер с новыми значениями, но предварительно отключая событие change. И навешивая его снова после установки новых значений.

    // Обновление слайдера с отключением события change
    function _updatePrices(options) {
        ui.$prices.slider({
            change: null
        }).slider({
            min: options.minPrice,
            max: options.maxPrice,
            values: [options.minPrice, options.maxPrice]
        }).slider({
            change: _getData
        });
        _updatePricesUI(options);
    }

Это и есть третье место, где нам пригодилась _updatePricesUI. Итак, новую функцию _updatePrices мы написали, что же с ней делать? А нужно заиспользовать ее в _catalogSuccess вместо _initPrices. Именно там у нас происходит смена цен и лишние 2 раза вызывается _getData. Просто заменим _initPrices на _updatePrices и убедимся, что теперь все работает как надо. Смена категорий инициирует один запрос на сервер.

И на этом все!


Итоги и ссылки

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

Список всех уроков серии:

Посмотреть, как это все работает, можно на том же демо интернет-магазина

И самое главное, конечно же, исходники. Доступны все по той же старой ссылке, где хранится весь проект, связанный с интернет-магазинами - Исходники интернет-магазина

Внимание! Кому интересна тема более сложных фильтров, не пропустите опрос - Нужны ли сложные динамические фильтры?

Все об интернет-магазинах

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