Постраничная навигация по товарам в интернет-магазине

июнь 10 , 2017
Предыдущая статья Следующая статья Демо Исходники

Когда в интернет-магазине набирается хотя бы 3 десятка товаров, встает вопрос: как удобнее выводить списки товаров посетителям? Пока преобладают 2 способа: постраничное разбиение и динамическая подгрузка по мере прокрутки страницы. Впрочем, реализация обоих вариантов ничем особенно не отличается, кроме небольших изменений в javascript-коде.

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


Что сделаем и как это выглядит

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

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

Итак, у нас кнопки категорий, селект, позволяющий выбрать, сколько товаров мы хотим видеть одновременно, сама навигация - список страниц плюс кнопки вперед-назад-в начало-в конец, и информационная строка, какой диапазон товаров выводится и сколько их всего. Общее количество товаров меняется в зависимости от выбранной категории. Осталось реализовать функционал. Начнем с создания страницы catalog-pag.html и встраивания ее в интернет-магазин.


Создаем новую страницу

Назывем ее catalog-pag.html, соответствующий js-модуль - catalogPag.js. Еще нам нужно будет дописать пару строк в main.js, чтобы подключить этот новый js-модуль. Если Вы читали предыдущие уроки по магазинам, то ничего нового не узнаете. А если и подзабыли, то весь полный код найдете в исходниках. Я же приведу только отличающиеся моменты.

У тега body файла catalog-pag.html пишем атрибут data-page="catalogPag". В самом же body вот такое меню и пустой список id="goods"

    
    

Все стандартно: меню копипастим с любой предыдущей страницы, раздел goods из catalog.html. В catalogPag.js пока сделаем заготовку, чтобы просто работало

    'use strict';
    
    // Модуль каталога с пагинацией
    var catalogPag = (function($) {
    
        // Инициализация модуля
        function init() {
            console.log('init catalogPag');
        }
    
        // Экспортируем наружу
        return {
            init: init
        }
        
    })(jQuery);

И подключим этот модуль в main.js, добавив в функцию init такие строки

    if (page === 'catalogPag') {
        catalogPag.init();
        cart.init(optionsCatalog);
    }

Заготовка есть, страница появилась - все отлично. Переходим к верстке.


Верстка

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

    

Товаров на странице:
Показаны товары: 1 - 3 из 5

Ничего необычного. Разве что хочу пояснить про саму навигацию. В примере верстки у нас 3 страницы, причем 3-я как раз активная. Как именно строить навигацию, дело Ваше, но я приведу максимально возможный упоротый вариант. Это кнопки "В начало", "Предыдущая страница", собственно кнопки-номера страниц, "Следующая страница" и "В конец". Так как последняя страница сейчас активна, то кнопки "следующая" и "в конец" мы не показываем. Вы же можете их отрисовывать, но делать недоступными или придумать что-то еще. В общем, для примера делаем по максимуму, в реальном случае лишнее отрубите.


Шаблон underscore для вывода товаров

А его мы просто скопипастим со страницы catalog.html

    

Теперь вроде бы стоит писать js-код, собирать данные со страницы и отправлять их на сервер. Но сегодня мы разнообразия ради нарушим эту традицию и сначала напишем серверный код. Когда мы вернемся к javascript-у, у нас будет полностью готов бекенд и отдача товаров.


Серверная часть: выносим общий код

Небольшой оффтоп. Когда я начинал писать статьи про интернет-магазины, то не подозревал, в какой проект это выльется. И серверный код с точки зрения его структуры был совершенно простой. До такой степени, что подключение к mysql просто копировалось из файла в файл. Теперь это меня достало и пора безобразие исправить.

Старый код трогать не будем, но для нового сделаем отдельный файлик common.php (какое великолепное название!). В этот файл будем закидывать общие функции-хелперы и константы, нужные для всего магазина. Начнем с подключения к базе данных.

Итак, scripts/common.php

    // Объявляем нужные константы
    define('DB_HOST', 'localhost');
    define('DB_USER', 'root');
    define('DB_PASSWORD', 'root');
    define('DB_NAME', 'webdevkin');
    
    // Подключаемся к базе данных
    function connectDB() {
        $errorMessage = 'Невозможно подключиться к серверу базы данных';
        $conn = new mysqli(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME);
        if (!$conn)
            throw new Exception($errorMessage);
        else {
            $query = $conn->query('set names utf8');
            if (!$query)
                throw new Exception($errorMessage);
            else
                return $conn;
        }
    }

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


Серверная часть: разбиение по страницам

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

Что идет на вход скрипту? Id категории, номер страницы и сколько товаров нужно вытащить (лимит количества) - трех параметров достаточно.

Что нужно на выходе? Во-первых, сам список товаров. Во-вторых, нам обязательно нужно знать, сколько всего товаров попадают под дополнительные условия (в нашем случае, категория). Общее количество товаров нужно для определения числа страниц в навигации и для информирования покупателей. Всем этим будет заниматься клиент, от сервера же требуется только одно число.

В целом определили, что нужно, приступим к коду. Сначала общий вид catalog_pag.php

    include_once './common.php';
    
    // Получение данных из массива _GET
    function getOptions() {
        $categoryId = (isset($_GET['category'])) ? (int)$_GET['category'] : 0;
        $page = (isset($_GET['page'])) ? (int)$_GET['page'] : 1;
        $limit = (isset($_GET['limit'])) ? (int)$_GET['limit'] : 5;
    
        return array(
            'category_id' => $categoryId,
            'page' => $page,
            'limit' => $limit
        );
    }
    
    // Получение товаров
    function getGoods($options, $conn) {
        // Основной код
        ...
    }
    
    
    try {
        // Подключаемся к базе данных
        $conn = connectDB();
    
        // Получаем данные от клиента
        $options = getOptions();
    
        // Получаем товары
        $data = getGoods($options, $conn);
    
        // Возвращаем клиенту успешный ответ
        echo json_encode(array(
            'code' => 'success',
            'data' => $data
        ));
    }
    catch (Exception $e) {
        // Возвращаем клиенту ответ с ошибкой
        echo json_encode(array(
            'code' => 'error',
            'message' => $e->getMessage()
        ));
    }

Сначала подключаем наш замечательный common.php, потом пишем функцию getOptions для вытаскивания нужных параметров из массива $_GET. Дальше основной код getGoods, напишем его ниже. И наконец, основной поток скрипта, думаю, что в нем происходит, понятно по комментариям. Теперь самое интересное: функция получения товаров.

Давайте еще раз подумаем (что-то часто сегодня приходится думать), как удобнее написать код. Нам нужно выдернуть из базы 2 вещи. Первое: массив товаров нужной категории. Но не все товары, а только определенное количество (limit), начиная с нужной страницы (page). И второе: количество всех товаров из этой категории, но уже без учета пагинации.

Сразу напрашивается мысль, что для этого нужно 2 запроса. Мысль правильная, другое дело, как эти запросы составить? Давайте попробуем решить задачу в лоб. Упростим условия и напишем пару sql-запросов. Вот так бы мы выбрали 5 товаров из базы, начиная с 10го, причем отсортированных по цене.

    select * from goods where category_id=1 order by price asc limit 10, 5

Ничего сложного. А вот так мы вытащим количество всех товаров с указанной категорией

    select count(*) as count_all from (select * from goods where category_id=1) as tmp

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

    select * from goods where category_id=1

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

    queryBase order by price asc limit 10, 5
    select count(*) as count_all from (queryBase) as tmp

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

    // Получение товаров
    function getGoods($options, $conn) {
        // Вычисляем номер страницы и параметры для sql limit
        $page = $options['page'];
        $limit = (int)$options['limit'];
        $start = ($page - 1) * $limit;
    
        // Категория, если есть
        $categoryId = $options['category_id'];
        $categoryWhere =
            ($categoryId !== 0)
                ? " g.category_id = $categoryId and "
                : '';
    
        // Заготовка запроса, на нем базируется запрос с общим количеством товаров и запрос с сортировками и страницами
        $queryBase = "
            select
                g.id as good_id,
                g.good as good,
                g.category_id as category_id,
                b.brand as brand,
                g.price as price,
                g.rating as rating,
                g.photo as photo
            from
                goods as g,
                brands as b
            where
                $categoryWhere
                g.brand_id = b.id
        ";
    
        // Запрос на общее количество товаров с указанной категорией
        $queryCountAll = 'select count(*) count_all from (' . $queryBase . ') as tmp';
        $data = $conn->query($queryCountAll);
        $row = $data->fetch_assoc();
        $countAll = (int)$row['count_all'];
    
        // Запрос с итоговыми данными
        $queryTotal = $queryBase . "
                order by price asc
                limit $start, $limit
            ";
        $data = $conn->query($queryTotal);
        $goods = $data->fetch_all(MYSQLI_ASSOC);
    
        // Возвращаем результат
        return array(
            'countAll' => $countAll,
            'goods' => $goods
        );
    }

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

А если Вы уже разбирали оные статьи, то видите, что кода меньше и он проще :-)

Сначала определяем переменные $start и $limit для конструкции limit в sql. Затем проверяем наличие категории. Дальше составляем queryBase и 2 итоговых запроса. Выполняем каждый запрос и возвращаем данные в массиве. Это все добро уходит на клиент, к написанию которого мы и переходим.


Клиентская часть: первичный рендер каталога

Базовая структура catalogPag.js у нас есть, давайте наполним ее полезной начинкой. Сначала выделим все нужные dom-элементы и underscore-шаблон в отдельные переменные.

    var ui = {
        $categoryBtn: $('.js-category'),
        $limit: $('#pages-limit'),
        $pag: $('#pagination'),
        $goods: $('#goods'),
        $goodsInfo: $('#goods-info')
    };
    var goodsTemplate = _.template($('#goods-template').html());

В объект ui мы загнали все dom-элементы, с которыми будем работать, в goodsTemplate - подготовленный шаблон underscore. Теперь при инициализации приложения нужно собрать данные (категорию, номер страницы и лимит) и отправить на сервер. Благо серверный код у нас уже есть. Затем дождаться ответа от бекенда и на основе пришедших с сервера данных отрисовать каталог.

Действуем так. Сначала из функции init уберем тестовый console.log и вместо нее напишем вызов функции получения данных с сервера

    // Инициализация модуля
    function init() {
        _getData();
    }

Теперь реализация _getData()

    // Получение данных
    function _getData() {
        var options = _getOptions();
        $.ajax({
            url: 'scripts/catalog_pag.php',
            data: options,
            type: 'GET',
            cache: false,
            dataType: 'json',
            success: function(response) {
                if (response.code === 'success') {
                    _renderCatalog(response.data.goods);
                } else {
                    console.error('Произошла ошибка');
                }
            }
        });
    }

Это обычный ajax-запрос на уже написанный php-скрипт. Сначала в _getOptions собираем данные для скрипта, потом отправляем запрос. В колбэке success проверяем код ответа, и если он равен "success", то вызываем _renderCatalog с товарами. Вот реализация _getOptions

    // Получение опций-настроек для товаров
    function _getOptions() {
        var categoryId = +$('.js-category.active').attr('data-category'),
            page = +ui.$pag.find('li.active').attr('data-page') || 1,
            limit = +ui.$limit.val();

        return {
            category: categoryId,
            page: page,
            limit: limit
        }
    }

Ничего необычного: ищем активные элементы категории и страницы, и вытаскиваем данные из нужных атрибутов. С селектом limit еще проще - берем его значение и все. Полученные числа заворачиваем для удобства в объект. Как мы помним, на сервере скрипт ждет переменные category, page и limit - как раз они и попадут в итоговый объект.

Дальше _renderCatalog

    // Рендер каталога
    function _renderCatalog(goods) {
        ui.$goods.html(goodsTemplate({goods: goods}));
    }

Шаблон underscore у нас уже есть, данные с сервера есть, осталось только отрендерить html с этими данными и полученную разметку подставить в контейнер ui.$goods.

С базовым рендером все. Обновите страницу и увидите отрисованный каталог. Впрочем, мы уже видели его на соседних вкладках, поэтому пока интересного мало. Давайте лучше разберемся непосредственно с пагинацией.


Пагинация, преобразуем верстку в шаблон underscore

Вспомним, как выглядит верстка пагинации

    

Первая кнопка "В начало", вторая "Предыдущая", далее рисуется по кнопке на каждый номер страницы. "Следующая" и "В конец" не отрисованы, потому что 3 (она же последняя) кнопка активна. Дальше нее уже не пройдешь.

Теперь это добро нужно завернуть в underscore-шаблон. Чтобы полностью отрисовать такую систему, нам нужно знать номер текущей страницы и количество страниц в целом. Тогда мы сможем перебрать все варианты, поймем, выводить или нет крайние кнопки, ставить класс активности и прочее. Вот такой получится шаблон, который мы закинем в catalog-pag.html рядом с шаблоном товаров.

    

Выглядит несколько пугающее, но давайте присмотримся внимательнее. В шаблоне используются 2 переменные: page - текущая страница и countPages - общее число страниц. Откуда они берутся, разберется js-код чуть ниже, нас пока интересует только шаблон.

В первом условии проверяем, не первая ли страница нам попалась? Если не первая, то рисуем кнопку "В начало" с data-page="1" и "Предыдущая" с номером страницы на 1 меньше текущей.

Дальше в цикле мы перебираем все страницы и для каждой рисуем свою кнопку с нужным data-page. Единственно, что нужно не забыть, это проверять, не совпадает ли рисуемая страница с номером i с page - текущей страницей. Если совпадает, то добавляем кнопке li класс active.

И последний блок с кнопками "Следующая" и "В конец" выводится ровно по аналогии с начальным. С той разницей, что сравнение идет с последней страницей и следующая имеет номер page + 1.

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


Рендер пагинации и информации о товарах

Первое, что мы сделаем - это добавим только что созданный шаблон в javascript-модуль. goodsTemplate уже есть, припишем еще pagTemplate.

    var goodsTemplate = _.template($('#goods-template').html()),
        pagTemplate = _.template($('#pagination-template').html());

Дальше вызовем новую функцию _renderPagination после рендера товаров. Это будет в функции _getData в колбэке success. Для контекста приведу весь обновленный код.

    // Получение данных
    function _getData() {
        var options = _getOptions();
        $.ajax({
            url: 'scripts/catalog_pag.php',
            data: options,
            type: 'GET',
            cache: false,
            dataType: 'json',
            success: function(response) {
                if (response.code === 'success') {
                    _renderCatalog(response.data.goods);
                    
                    // НОВОЕ
                    _renderPagination({
                        page: options.page,
                        limit: options.limit,
                        countAll: response.data.countAll,
                        countItems: response.data.goods.length
                    });
                } else {
                    console.error('Произошла ошибка');
                }
            }
        });
    }

В _renderPagination передадим объект с четырьмя полями: текущая страница, лимит, количество всех товаров и количество выводимых сейчас товаров. Первые 2 получим из ранее собранных настроек, последние 2 придут с сервера. Обратите внимание: countItems не обязательно равен limit - количеству товаров на странице, заданное в селекте. Например, если у нас 12 товаров, а лимит - 5 штук на странице, то на последней, третьей странице, лимит будет так же 5, а вот countItems - 2.

Так, данные собрали, их хватит на то, чтобы и отрисовать пагинацию, и вывести информацию вида "показываем 6 - 10 товаров из 14". Код будет такой.

    // Рендер пагинации
    function _renderPagination(options) {
        var countAll = options.countAll,
            countItems = options.countItems,
            page = options.page,
            limit = options.limit,
            countPages = Math.ceil(countAll / limit),
            start = (page - 1) * limit + 1,
            end = start + countItems - 1;

        // Информация о показываемых товарах
        var goodsInfoMsg = start + ' - ' + end + ' из ' + countAll;
        ui.$goodsInfo.text(goodsInfoMsg);

        // Рендер пагинации
        ui.$pag.html(pagTemplate({
            page: page,
            countPages: countPages
        }));
    }

Сначала заведем кучу переменных. Первые 4 просто возьмем из опций. А для следующих трех вспомним арифметику. Количество страниц countPages - это количество всех товаров, разделенное на количество их на странице и округленное до целого в большую сторону. start и end - это для текста "показываем товары с такого по такой".

После этого останется из этих переменных составить информацию goodsInfoMsg и закинуть текущую страницу с их общим количеством в шаблон pagTemplate. Уф, чтобы разобраться с этим, возможно, придется осмыслить код еще не раз, но я верю в Ваши силы. Вы уже молодец, что вообще добрались до этих строк :-)

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


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

У нас имеются 3 элемента интерфейса, которые ждут кликов: кнопки категорий, селект с выбором количества товаров на странице и сама пагинация. Общий код навешивания событий на оные элементы выглядит так

    // Инициализация модуля
    function init() {
        _getData();
        
        // НОВОЕ
        _bindHandlers();
    }

    // Привязка событий
    function _bindHandlers() {
        ui.$categoryBtn.on('click', _changeCategory);
        ui.$limit.on('change', _changeLimit);
        ui.$pag.on('click', 'a', _changePage);
    }

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

Конечно, я по своей милой привычке обломаю читателя и обговорю еще одну деталь. Но без нее, друзья, не обойтись. Суть проста: при переключении категории или количества товаров на странице нам нужно сбрасывать номер текущей страницы на 1.

Зачем? Представьте, что мы находимся на 5 странице в ноутбуках при 10 товарах на странице. И тут мы переходим в телефоны, где всего-то 20 товаров. То есть 5 страницы не существует. Что показывать? А если бы даже страница и была, смена категории - это существенное обновление ассортимента, и кидать посетителя сайта куда-то в середину каталога будет странно.

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

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

Во-первых, в _getData. Теперь ее начало будет такое

    // Получение данных
    function _getData(options) {
        var resetPage = options && options.resetPage,
            options = _getOptions(resetPage);
        $.ajax({
            ...
        });

Добавился на вход объект options, в котором лежит resetPage. Этот resetPage передадим в _getOptions. Во-вторых, чуть изменим саму _getOptions.

    // Получение опций-настроек для товаров
    function _getOptions(resetPage) {
        var categoryId = +$('.js-category.active').attr('data-category'),
            page = !resetPage ? +ui.$pag.find('li.active').attr('data-page') : 1,
            limit = +ui.$limit.val();

        return {
            ...
        }
    }

Здесь все изменения - это параметр resetPage на вход функции и чуть исправленное получение переменной page. Остальное то же самое.

И в-третьих, этот новый параметр resetPage нужно передать в единственный пока вызов _getData нашего приложения - в функции init.

    // Инициализация модуля
    function init() {
        _getData({
            resetPage: true
        });
        _bindHandlers();
    }

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

    // Смена категории
    function _changeCategory(e) {
        var $category = $(e.target);
        ui.$categoryBtn.removeClass('active');
        $category.addClass('active');

        _getData({
            resetPage: true
        });
    }

    // Смена лимита
    function _changeLimit() {
        _getData({
            resetPage: true
        });
    }

    // Смена страницы
    function _changePage(e) {
        e.preventDefault();
        e.stopPropagation();

        var $page = $(e.target).closest('li');
        ui.$pag.find('li').removeClass('active');
        $page.addClass('active');

        _getData();
    }

Код всех трех обработчиков очень похож. Меняем активную категорию или страницу и вызываем _getData, не забывая указывать, надо ли сбрасывать страницу на первую. А дальше _getData все сделает и отрисует сама. Вот теперь точно все!

Напоминаю адрес демо-магазина, где Вы можете поиграть с вышесозданным функционалом - Каталог с пагинацией


Заключение

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

Ссылки на демо и обновленные исходники интернет-магазина чуть ниже. Я завел для единообразия большие фиолетовые кнопки, чтобы было проще искать нужные ссылки :-)

Как всегда, комментарии и вопросы не возбраняются.

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

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