Учим контроллеры работать через Ajax

Неожиданно выяснилось, что я обещал написать о работе контроллеров через Ajax, но забыл:
Позже мы научим наши контроллеры обрабатывать запросы и выдавать ответы через Ajax.
Так что пишу еще один, самый-самый последний урок из курса =)
Общие принципы работы через Ajax заключаются в том, что
  • javascript посылает запрос на сервер, используя метод XMLHttpRequest

  • Контроллер определяет, что запросы был сделан через ajax и отвечать нужно в определённом формате - мы любим JSON

  • При успехе или ошибке на фронтенд всегда возвращается массив с данными установленного формата

  • В зависимости от ответа, javascript что-то делает со страницей

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

Общая поддержка ajax

Ajax - штука крайне полезная, поэтому лучше предусмотреть работу с ней во всех контроллерах, глобально. Для этого мы меняем классы Controller и Core.
Первым делом, добавляем в Controller публичное свойство $isAjax:

/** @var bool $isAjax */
public $isAjax = false;
А класс Core учим добавлять в него значение при загрузке контроллера в методе handleRequest:
$controller->isAjax = isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest';
Значение HTTP_X_REQUESTED_WITH в массиве свойств сервера, равное XMLHttpRequest, выставляет jQuery при отправке любого запроса через ajax, так что можно смело рассчитывать на него при проверке. Меня этот способ ни разу не подводил.
Теперь любой контроллер может проверять у себя переменную $this->isAjax, чтобы знать, в каком режиме он работает.
А чтобы все контроллеры отвечали одинаково, нам нужно создать новую функцию Core::ajaxResponse:

    /**
     * Вывод ответа в установленном формате для всех Ajax запросов
     *
     * @param bool|true $success
     * @param string $message
     * @param array $data
     */
    public function ajaxResponse($success = true, $message = '', array $data = array()) {
        $response = array(
            'success' => $success, // успех или ошибка операции
            'message' => $message, // произвольное сообщение о статусе операции
            'data' => $data, // массив с данными для работы, который выдал контроллер
        );

        exit(json_encode($response)); // прерываем всю работу и выдаём ответ в JSON
    }
Давайте перепишем Controller::redirect для поддержки ajax:

    public function redirect($url = '/') {
        if ($this->isAjax) {
            $this->core->ajaxResponse(false, 'Редирект на другой адрес', array('redirect' => $url));
        }
        else {
            header("Location: {$url}");
            exit();
        }
    }
Ajax запрос выполняется в фоновом режиме, поэтому мы не можем сделать через него редирект. Всё, что может сервер, это отправить данные на фронтенд и там уже что-то произойдёт (или нет).
Поэтому метод для редиректа в контроллерах мы переписываем так, чтобы при работе через ajax возвращалась ошибка и адрес, на который юзера можно отправить средствами javascript.
Запрос приходит в контроллер, тот понимает, что работает в режиме ajax, выдаёт ответ в формате JSON и это прерывает работу.
Но что делать, если контроллер не умеет работать через ajax? В таком случае нам вернётся содержимое страницы, как если бы она была запрошена обычно, и тогда javascript выдаст ошибку разбора ответа - это плохо. Мы же договорились, что на ajax запросы будет всегда возвращается JSON ответ.
Если контроллер может работать через ajax, то он сам выдаст ответ через Core::ajaxResponse и до возврата данных через Core::handleRequest дело просто не дойдёт (потому что в ajaxResponse функция exit(), которая прерывает любую работу).
Таким образом, если Core::handleRequest в режиме ajax еще работает при выдаче результата - нужно заменить его на JSON ошибку:

        if ($controller->isAjax) {
            $this->ajaxResponse(false, 'Не могу обработать ajax запрос');
        }
        else {
            echo $response;
        }
Вот теперь наши основные классы готовы к работе через ajax (см. коммит с изменениями). Переходим к контроллеру News.

Ajax пагинация

Так как при ajax пагинации мы будем возвращать список статей и саму пагинацию в разных ключах массива, нам придётся разделить шаблон news.tpl на составные части и добавить им идентификаторы:

{block 'content'}
    {if $items}
        <div id="news-wrapper">
            <div id="news-items">
                {insert '_news.tpl'}
            </div>
            <div id="news-pagination">
                {if $pagination}
                    {insert '_pagination.tpl'}
                {/if}
            </div>
        </div>
    {else}
        <a href="/news/">← Назад</a>
        {parent}
    {/if}
{/block}
_news.tpl и _pagination.tpl содержат код из старого шаблона "как есть", без изменений - поэтому я не буду его здесь дублировать.
Теперь, при обычной работе, шаблон news.tpl подключает дочерние для отображения списка новостей и пагинации, а вот при работе через ajax, наш контроллер может отрисовать их раздельно и выдать в JSON. Чтобы мы могли нормально заменить части страницы при помощи javascript, они обернуты в новые элементы с идентификаторами, типа
<div id="news-wrapper">
    ...
</div>
Дорабатываем метод Controller\News::run:

        // Проверяем режим работы
        if ($this->isAjax) {
            // Если контроллер отображает одну новость - выдаём ошибку, это не пагинация
            if ($this->item) {
                $this->core->ajaxResponse(false, 'Контроллер News не принимает ajax в режиме показа отдельной новости');
            }
            // Ошибки нет, работаем штатно
            $items = $this->getItems();
            $pagination = $this->getPagination($this->_total, $this->page, $this->limit);
            $this->core->ajaxResponse(true, '', array(
                // Отдельный рендер списка новостей
                'items' => $this->template('_news', array('items' => $items), $this), 
                // Отдельный рендер постраничной навигации
                'pagination' => $this->template('_pagination', array('pagination' => $pagination), $this),
                // Служебные данные, на всякий случай
                'total' => $this->_total, // Сколько всего страниц
                'page' => $this->page, // Текущая страница
                'limit' => $this->limit, // Количество статей на страницу
            ));
        }
        // Дальше всё идёт как раньше
Если контроллер работает через ajax, то он стопроцентно ответит на запрос и выдаст или ошибку (если запрос был к конкретной новости, а не списку), или массив с данными, которыми мы можем заменить содержимое на странице.
Осталось еще немного изменить метод Controller\News::initialize, чтобы он не выдавал нам редирект с первой страницы при работе через ajax:

            // Добавляем проверку isAjax для первой страницы
            if (!$this->_offset && !$this->item && !$this->isAjax) {
                $this->redirect("/{$this->name}/");
            }
Обращаю ваше внимание, что при всех ошибках (запрос неверной страницы или неправильные данные в запросе) редирект будет срабатывать как и раньше, только при ajax режиме это будет в виде ответа с ошибкой и адреса для редиректа.
Вот коммит с изменениями шаблонов и контроллера News. Нам осталось только добавить сам javascript, который будет запрашивать страницы.

Javascript для ajax пагинации

Создаём файл main.js в /assets/js и добавляем его в шаблона base.tpl:

<footer>
    {block 'js'}
        <script src="/assets/js/jquery-2.1.4.min.js"></script>
        <script src="/assets/js/bootstrap.min.js"></script>
        <script src="/assets/js/main.js"></script>
    {/block}
</footer>
Пишем там несложный jQuery код:

// Вешаем обработчик на нажатия кнопок постраничной навигации
$('#news-wrapper').on('click', '#news-pagination a', function() {
    var href = $(this).attr('href'); // Определяем ссылку
    // Пустые ссылки не обрабатываем
    if (href != '') {
        // Для индикации работы через ajax делаем элемент-обёртку полупрозрачным
        var wrapper = $('#news-wrapper');
        wrapper.css('opacity', .5);
        // Запрашиваем страницу через ajax
        $.get(href, function(res) {
            // При получении любого ответа делаем обёртку обратно непрозрачной 
            wrapper.css('opacity', 1);
            // Получен успешный ответ
            if (res.success) {
                // Меняем содержимое элементов
                // новости
                $('#news-items').html(res.data['items']);
                // постраничная навигация
                $('#news-pagination').html(res.data['pagination']);
            }
            // Ответ с ошибкой и в массиве данных указан адрес перенаправления
            else if (res.data['redirect']) {
                // Редиректим пользователя
                window.location = res.data['redirect'];
            }
            // Иначе пишем ошибку в консоль и больше ничего не делаем
            else {
                console.log(res);
                // А вообще, здесь можно и вывести ошибку на экран
                // alert(res.data['message']);
            }
        }, 'json');
    }
    // В любом случае не даём перейти по ссылке - у нас же тут ajax пагинация
    return false;
});
Всё, постраничная навигация работает через ajax, можно проверять! Вот коммит с нашим javascript.

Бонус

Работать-то работает, но на дворе 2015 год, а у нас даже не меняется адрес страницы при загрузке. Нужно добавить работу с адресом в браузере.
Тут возможно два варианта:
  1. Новый браузер, который поддерживает history.js и умеет менять url в браузере без перезагрузки страницы

  2. Очень старый браузер, в котором нужно работать через hash

Второй случай я рассматривать не буду, потому что такие браузеры еще нужно поискать, а вот работу с history.js мы с вами быстренько добавим.
Во-первых, определяем, есть ли у браузера нужный нам функционал:

var oldBrowser = !(window.history && history.pushState);
if (!oldBrowser) {
    // Добавляем в историю текущую страницу при первом открытии раздела
    history.replaceState({pagination: window.location.href}, '');
}
Все свои данные мы добавляем в историю с ключом pagination, чтобы потом его можно было проверить и понять - это именно наши данные.
Во-вторых, выделяем непосредственно загрузку страницы в отдельную функцию loadPage, чтобы можно было вызывать её из любого места скрипта:

function loadPage(href) {
    var wrapper = $('#news-wrapper');
    wrapper.css('opacity', .5);
    $.get(href, function(res) {
        wrapper.css('opacity', 1);
        if (res.success) {
            $('#news-items').html(res.data['items']);
            $('#news-pagination').html(res.data['pagination']);
        }
        else if (res.data['redirect']) {
            window.location = res.data['redirect'];
        }
        else {
            console.log(res);
        }
    }, 'json');
}
В-третьих, переписываем обработчик события нажатий на ссылки навигации, чтобы он использовал loadPage:

$('#news-wrapper').on('click', '#news-pagination a', function() {
    var href = $(this).attr('href');

    if (href != '') {
        // Если браузер не старый - добавляем адрес страницы ему в историю
        if (!oldBrowser) {
            window.history.pushState({pagination: href}, '', href);
        }
        // И загружаем её
        loadPage(href);
    }

    return false;
});
Ну и последний штрих - вешаем обработчик на нажатие кнопок взад-вперёд в браузере popstate:
$(window).on('popstate', function(e) {
    // Проверяем данные внутри события, и если там наш pagination
    if (e.originalEvent.state && e.originalEvent.state['pagination']) {
        // То загружаем сохранённую страницу
        loadPage(e.originalEvent.state['pagination']);
    }
});
Вот теперь точно всё. Страницы загружаются через ajax, адрес в браузере меняется, при нажатии кнопок истории всё обновляется как положено. Проверить можно на тестовом сайте, а вот здесь коммит с изменениями.

Заключение

Как видите, нам пришлось добавить не так уж и много изменений в нашем движке, для поддержки работы через Ajax во всех контроллерах. Причём, мы позаботились и о тех контроллерах, которые к ajax еще не готовы.
На мой взгляд, это показатель хорошего, продуманного движка, когда его можно так легко и просто расширить, не ломая имеющийся функционал.
Всего мы добавили 100 строк, из которых 41 - это чисто javascript.
Надеюсь, вам понравился мой курс работы на чистом PHP, и теперь вы лучше понимаете как работает MODX и другие движки.

P.S.

После тестирования изменений нашёлся интересный баг в браузере Google Chrome - он кэширует ответы от сервера, если они без параметров в URL (наш случай friendly urls).
Если походить по ajax навигации, потом уйти на обычную страницу и вернуться через историю, то Chrome выводит чистый JSON ответ сервера из своего кэша, без оформления страницы.
Исправил это переписыванием запроса на сервер с отключение кэша. Кому интересны подробности, можно почитать обсуждение в репозитории Chromium.

6 комментариев

Отличный курс! После прохождения лучше стал понимать работу модкса. Спасибо автору.
Василий Наумкин
На здоровье!
Антон Фомичёв
Я бы всё-таки js тоже привел к ООП-стандартам. Пока проект маленький, это не критично. Но как только он разрастётся, поддерживать ворох функций js, работающих в глобальной области видимости будет лчень трудоёмко.
Василий Наумкин
Он не разрастётся - это просто пример.
Антон Фомичёв
Этот - понятно:)) Я имел ввиду, какой-нибудь реальный проект, который создаст разработчик, пользуясь полученными здесь знаниями. Я прекрасно помню, как я намучался с хаотично разбросанными функциями в одном не самом крупном проекте.
Но это ладно. Вообще я ещё больше укрепился во мнении, что этот курс самый полезный из всех. Здесь ты не "кормишь рыбой", а именно "даёшь удочку". Мне лчень понравилось всё, спасибо. Из хотелок (я понимаю, что курс завершён и не предлагаю его возобновлять, просто на будущее), было бы здорово рассмотреть различные способы авторизации и сопутствующие вещи - токены, хранение сессий, куки, удалённая авторизация, авторизация с использованием access- и refresh-токенов.
Ещё раз, большое тебе спасибо!
Василий Наумкин
На здоровье!
Если буду когда-то продолжать этот курс - конечно.
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Дмитрий
21.12.2024, 13:27:06
Здравствуйте.В ModX есть полезная функция "заморозить url родителя". При ее включении вместо: УРЛ п...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!