Постраничная навигация по товарам в интернет-магазине
Когда в интернет-магазине набирается хотя бы 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 все сделает и отрисует сама. Вот теперь точно все!
Напоминаю адрес демо-магазина, где Вы можете поиграть с вышесозданным функционалом - Каталог с пагинацией
Заключение
Несколько объемной получилась статья про пагинацию, но зато мы подробно рассмотрели весь процесс разбиения на страницы. И серверная, и клиентская части оказались вполне доступными для понимания. В предыдущих уроках приходилось писать вещи и посложнее. Впрочем, любая задача упрощается, когда начинаем подробно над ней размышлять и писать код.
Ссылки на демо и обновленные исходники интернет-магазина чуть ниже. Я завел для единообразия большие фиолетовые кнопки, чтобы было проще искать нужные ссылки :-)
Как всегда, комментарии и вопросы не возбраняются.
Все об интернет-магазинах
- Демо интернет-магазина
- Корзина интернет-магазина. С чего все началось
- Оформляем заказ на клиенте и сервере
- Добавляем доставку
- Фильтры и сортировки на клиенте и сервере
- Урок 0. Вводный
- Урок 1. Структура базы данных
- Урок 2. Структура проекта и верстка
- Урок 3. Сбор данных на клиенте и отправка на сервер
- Урок 4. Пишем базовый php-код и sql-запросы
- Урок 5. Прием данных с сервера и рендеринг на клиенте
- Урок 6. Заключительный, дорабатываем некоторые штрихи
- Сравнение товаров
- Постраничная навигация по товарам
- Преобразуем каталог, переключаем внешний вид товаров одной кнопкой
- Отправка sms при оформлении заказа
- Админка интернет-магазина на vue.js - серия уроков
- Авторизация на сессиях. Делаем логин в админке
- Docker для начинающих. Докеризуем интернет-магазин
Истории из жизни айти и обсуждение кода.