Постраничная навигация по товарам в интернет-магазине
Когда в интернет-магазине набирается хотя бы 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 для начинающих. Докеризуем интернет-магазин
Истории из жизни айти и обсуждение кода.