Постраничный вывод данных: курсорная и offset пагинация
Методы организации постраничной навигации
Как реализовать базовую пагинацию с LIMIT и OFFSET?
Наиболее распространенный способ использование параметра page в URL, например index.php?page=2. В PHP код получает номер страницы, вычисляет смещение и предел.
<?php
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$perPage = 10;
$offset = ($page - 1) * $perPage;
$stmt = $pdo->prepare('SELECT * FROM articles LIMIT :limit OFFSET :offset');
$stmt->execute(['limit' => $perPage, 'offset' => $offset]);
$articles = $stmt->fetchAll();
$totalStmt = $pdo->query('SELECT COUNT(*) FROM articles');
$total = $totalStmt->fetchColumn();
$totalPages = ceil($total / $perPage);
?>Index php next (постраничная навигация (следующая страница))
Ссылки навигации:
<?php if ($page > 1): ?>
<a href="?page=<?= $page-1 ?>">Назад</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
<a href="?page=<?= $i ?>"><?= $i ?></a>
<?php endfor; ?>
<?php if ($page < $totalPages): ?>
<a href="?page=<?= $page+1 ?>">Вперед</a>
<?php endif; ?>Проблемы:
- При больших OFFSET запрос становится медленным, так как БД нужно прочитать все предыдущие строки.
- Двойной запрос COUNT и выборка. Можно оптимизировать SQL_CALC_FOUND_ROWS, но это тоже не идеально.
- При частом обновлении данных может быть несоответствие количества страниц.
Решение:
Для больших объемов использовать курсорную пагинацию или кэширование COUNT.
Цель: простой и понятный вывод для сайтов с небольшим количеством записей до нескольких тысяч.
Как сделать пагинацию на основе курсора по id?
Вместо номера страницы передается идентификатор последнего элемента на предыдущей странице. Это позволяет избежать смещения.
<?php
$lastId = isset($_GET['next']) ? (int)$_GET['next'] : 0;
$perPage = 10;
$stmt = $pdo->prepare('SELECT * FROM articles WHERE id > :lastId ORDER BY id ASC LIMIT :limit');
$stmt->execute(['lastId' => $lastId, 'limit' => $perPage]);
$articles = $stmt->fetchAll();
?>Ссылка "Следующая страница" передает ID последней статьи на текущей странице:
<?php
$last = end($articles);
if ($last) {
$nextId = $last['id'];
echo '<a href="?next='.$nextId.'">Следующая страница</a>';
}
?>Проблемы:
- Невозможно перейти на произвольную страницу, только последовательно вперед.
- Требуется уникальный и сортируемый ключ, обычно id.
- При использовании GROUP BY или сложных фильтров курсор сложнее реализовать.
Решение: Комбинировать с кнопкой "Назад" через хранение предыдущих курсоров в сессии.
Цель: эффективная пагинация для больших таблиц, например ленты новостей.
Как реализовать постраничную навигацию с сохранением фильтров?
Если на странице заданы параметры фильтра, ссылки пагинации должны их сохранять.
<?php
function buildPageUrl($page, $params) {
$params['page'] = $page;
return '?' . http_build_query($params);
}
$currentParams = $_GET;
unset($currentParams['page']);
// Использование:
echo '<a href="' . buildPageUrl($page+1, $currentParams) . '">Вперед</a>';
?>Проблемы:
- Может возникнуть дублирование параметров, если не очищать page.
- При большом количестве параметров URL становится длинным.
Цель: фильтрация и поиск с постраничным выводом.
Как сделать бесконечную прокрутку AJAX пагинацию?
Загрузка следующей страницы происходит асинхронно при скролле или нажатии кнопки.
PHP endpoint get_page.php:
<?php
$page = $_GET['page'] ?? 1;
$perPage = 5;
$offset = ($page - 1) * $perPage;
$articles = $pdo->query("SELECT * FROM articles LIMIT $perPage OFFSET $offset")->fetchAll();
echo json_encode($articles);
?>JavaScript fetch:
let page = 1;
function loadMore() {
fetch(`get_page.php?page=${++page}`)
.then(res => res.json())
.then(data => {
data.forEach(item => {
// добавить элемент в DOM
});
});
}Проблемы:
- SEO: контент не индексируется, если не используется SSR.
- Пользователь не может вернуться к конкретной странице.
- Управление состоянием при перезагрузке страницы.
Решение: использовать History API или hash-навигацию.
Цель: динамические ленты, соцсети, где важна плавность загрузки.
Расширенные примеры постраничной навигации
Пример 1: Класс Paginator с генерацией полного набора ссылок
<?php
class Paginator {
private $total;
private $perPage;
private $currentPage;
public function __construct($total, $perPage, $currentPage) {
$this->total = $total;
$this->perPage = $perPage;
$this->currentPage = $currentPage;
}
public function getPages() {
$totalPages = ceil($this->total / $this->perPage);
$pages = [];
for ($i = 1; $i <= $totalPages; $i++) {
$pages[] = [
'number' => $i,
'url' => '?page=' . $i,
'active' => $i == $this->currentPage
];
}
return $pages;
}
public function render() {
$html = '<nav><ul class="pagination">';
$pages = $this->getPages();
foreach ($pages as $page) {
$class = $page['active'] ? ' class="active"' : '';
$html .= '<li' . $class . '><a href="' . $page['url'] . '">' . $page['number'] . '</a></li>';
}
$html .= '</ul></nav>';
return $html;
}
}
?>Результат при total=50, perPage=10, currentPage=3:
<nav><ul class="pagination"><li><a href="?page=1">1</a></li><li><a href="?page=2">2</a></li><li class="active"><a href="?page=3">3</a></li><li><a href="?page=4">4</a></li><li><a href="?page=5">5</a></li></ul></nav>
Пример 2: Курсорная пагинация с навигацией назад через сессию
<?php
session_start();
$perPage = 5;
$direction = $_GET['dir'] ?? 'next';
if ($direction === 'prev') {
$cursor = $_SESSION['prev_cursors'][count($_SESSION['prev_cursors'])-2] ?? 0;
array_pop($_SESSION['prev_cursors']);
} else {
$cursor = isset($_GET['next']) ? (int)$_GET['next'] : 0;
}
$stmt = $pdo->prepare('SELECT id, title FROM articles WHERE id > :cursor ORDER BY id LIMIT :limit');
$stmt->execute(['cursor' => $cursor, 'limit' => $perPage]);
$articles = $stmt->fetchAll();
if (count($articles) === $perPage) {
$lastId = end($articles)['id'];
$_SESSION['prev_cursors'][] = $cursor;
echo '<a href="?next='.$lastId.'">Следующая</a>';
}
if (!empty($_SESSION['prev_cursors'])) {
echo '<a href="?dir=prev">Предыдущая</a>';
}
?>Результат: страницы загружаются только вперед, но кнопка "Предыдущая" возвращает к предыдущему курсору.
<a href="?next=15">Следующая</a> <a href="?dir=prev">Предыдущая</a>
Пример 3: Сохранение всех GET параметров при пагинации
<?php
$currentQuery = $_GET;
$page = isset($currentQuery['page']) ? (int)$currentQuery['page'] : 1;
unset($currentQuery['page']);
$queryString = http_build_query($currentQuery);
$baseUrl = '?' . ($queryString ? $queryString . '&' : '') . 'page=';
echo '<a href="' . $baseUrl . ($page-1) . '">Назад</a>';
echo '<a href="' . $baseUrl . ($page+1) . '">Вперед</a>';
?>Результат при исходных параметрах ?category=3&sort=date:
<a href="?category=3&sort=date&page=1">Назад</a><a href="?category=3&sort=date&page=3">Вперед</a>
Пример 4: Бесконечная прокрутка с обработкой конца списка (PHP + JS)
// PHP скрипт get_more.php
<?php
$page = $_GET['page'] ?? 1;
$perPage = 3;
$offset = ($page - 1) * $perPage;
$data = $pdo->query("SELECT id, title FROM articles LIMIT $perPage OFFSET $offset")->fetchAll();
$hasMore = count($data) === $perPage;
echo json_encode(['items' => $data, 'hasMore' => $hasMore]);
?>
// JavaScript
let page = 1;
let loading = false;
window.addEventListener('scroll', function() {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100 && !loading) {
loading = true;
fetch(`get_more.php?page=${++page}`)
.then(r => r.json())
.then(data => {
if (data.hasMore) {
data.items.forEach(item => {
const div = document.createElement('div');
div.innerHTML = item.title;
document.body.appendChild(div);
});
loading = false;
} else {
// конец списка
}
});
}
});Результат: при скролле подгружаются новые элементы, флаг hasMore указывает на наличие следующей страницы.
(в консоли браузера последовательно появляются блоки с заголовками статей)