Учим контроллеры работать через 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.

Вот финальный коммит с исправлением.

Наверх, в раздел
Работа на чистом PHP
Предыдущая заметка
Добавляем новостям пагинацию


Комментарии ()

  1. Сергей 28 сентября 2015, 10:43 # 0
    Отличный курс! После прохождения лучше стал понимать работу модкса. Спасибо автору.
    1. Василий Наумкин 28 сентября 2015, 11:18 # 0
      На здоровье!
    2. Антон Фомичёв 03 ноября 2015, 02:26 # 0
      Я бы всё-таки js тоже привел к ООП-стандартам. Пока проект маленький, это не критично. Но как только он разрастётся, поддерживать ворох функций js, работающих в глобальной области видимости будет лчень трудоёмко.
      1. Василий Наумкин 03 ноября 2015, 06:12 # 0
        Он не разрастётся — это просто пример.
        1. Антон Фомичёв 03 ноября 2015, 07:53 # 0
          Этот — понятно:)) Я имел ввиду, какой-нибудь реальный проект, который создаст разработчик, пользуясь полученными здесь знаниями. Я прекрасно помню, как я намучался с хаотично разбросанными функциями в одном не самом крупном проекте.

          Но это ладно. Вообще я ещё больше укрепился во мнении, что этот курс самый полезный из всех. Здесь ты не «кормишь рыбой», а именно «даёшь удочку». Мне лчень понравилось всё, спасибо.
          Из хотелок (я понимаю, что курс завершён и не предлагаю его возобновлять, просто на будущее), было бы здорово рассмотреть различные способы авторизации и сопутствующие вещи — токены, хранение сессий, куки, удалённая авторизация, авторизация с использованием access- и refresh-токенов.

          Ещё раз, большое тебе спасибо!
          1. Василий Наумкин 03 ноября 2015, 07:59 # 0
            На здоровье!

            Если буду когда-то продолжать этот курс — конечно.
      Добавление новых комментариев отключено.