Постраничный вывод данных: курсорная и 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 указывает на наличие следующей страницы.

(в консоли браузера последовательно появляются блоки с заголовками статей)

Постраничная навигация (следующая страница) - comments

En
Index php next (php)