Heartbeat API

Heartbeat API
Click here to view original web page at wp-kama.ru

Heartbeat API - это легкий способ периодически (каждые 15-120 секунд) опрашивать сервер на предмет новых данных и затем использовать их на стороне клиента (браузера).

Heartbeat API был добавлен в WordPress 3.6 и по началу был нужен для двух вещей:

  • Предупреждать пользователя о том, что пост редактируется кем-то другим в данный момент.
  • Проверять, не истекла ли сессия авторизации и запуск popup-окна с просьбой авторизоваться повторно.

Со временем этим механизмом стали пользоваться темы и плагины.

Heartbeat API по умолчанию работает только в админ-панели, но его легко можно использовать и во фронте.

  1. После загрузки страницы специальный JavaScript код (heartbeat код) запускает таймер. Через равные промежутки времени срабатывает JS событие heartbeat-send и запускаются подцепленные на это событие JS функции.

  2. JS функции формируют и отсылают данные в файл admin-ajax.php, где их ждут php функции-обработчики, зацепленные на хук heartbeat_received.

  3. PHP Функция обработчик (после работы) возвращает обратно пользователю (в браузер) данные в формате JSON. При получении этих данных, срабатывает JS событие heartbeat-tick.
    На основе присланных данных производится та или иная работа: предложение авторизоваться снова, сообщения от клиента менеджеру, сообщения из чата Telegram, извещение о новых комментариях и так далее.

Heartbeat API по умолчанию доступен только в админке. Чтобы использовать этот механизм во фронте (лицевой части), надо подключить скрипт heartbeat.js или же указать его в зависимостях в своём скрипте.

// 1 Вариант. Подключаем JS-скрипт heartbeat
wp_enqueue_script( 'heartbeat' );

// 2 Вариант. Подключаем heartbeat, как зависимость в нашем скрипте
wp_enqueue_script( 'script-name', get_template_directory_uri() .'/js/example.js', array('heartbeat'), '1.0', true );

Далее для использования Heartbeat API, нужно пройти три этапа. Не важно, делается это для фронта или админки.

Событие heartbeat-send срабатывает перед отправкой данных на сервер и это самый удачный момент добавить свои данные в коллекцию данных Heartbeat.

В дефолтной сборке WordPress во вкладке браузера можно увидеть при ajax запросе нечто подобное:

interval: 60
_nonce: bab7ce80c5
action: heartbeat
screen_id: dashboard
has_focus: false

Добавим в эту коллекцию свои данные:

По правилам хорошего тона JS код надо размещать в файле и подключать на хуке admin_enqueue_scripts (если речь идет об админке), но для упрощения примера выведем его на хуке admin_print_footer_scripts

<?php
// Подключим наш скрипт в админке
add_action( 'admin_print_footer_scripts', function () {
	?>
	<script>
	jQuery(document).on( 'heartbeat-send', function (event, data) {
		// Добавляем свои данные в коллекцию данных Heartbeat.
		data.myplugin_field = 'some_data';
	});
	</script>
	<?php
} );

Во вкладке браузера можно посмотреть, как данные отправились:

data[myplugin_field]: some_data
interval: 60
_nonce: bab7ce80c5
action: heartbeat
screen_id: dashboard
has_focus: false

В примере была отправлена просто строка some_data, но отправлять можно любые данные: числа, массивы, объекты и т.д.

Прежде всего давайте посмотрим, что по умолчанию возвращает сервер:

{
	"wp-auth-check":true,
	"server_time":1520878390
}

На хуке heartbeat_received можно дополнить массив, которые формируют ядро/плагины/тема, своими данными:

// Фильтр для работы с присланными Heartbeat данными.
add_filter( 'heartbeat_received', 'myplugin_receive_heartbeat', 10, 2 );

/**
 * Принимает Heartbeat данные и формирует ответ.
 *
 * @param array $response Данные Heartbeat для отправки во фронтэнд.
 * @param array $data     Данные, присланные с фронтэнда (unslashed).
 *
 * @return array
 */
function myplugin_receive_heartbeat( $response, $data ) {
	// Если наши данные не пришли, то возвращаем оригинал ответа.
	if ( empty( $data['myplugin_field'] ) ) {
		return $response;
	}

	/**
	 * Обращаемся к нашим данным.
	 * Ключ массива совпадает со свойством объекта в JS, в который мы поместили данные.
	 */
	$received_data = $data['myplugin_field'];

	// Для примера посчитаем сколько в переданной строке символов.
	$count_symbol = mb_strlen( $received_data );

	$response['myplugin_strlen'] = 'Количество знаков в переданной фразе ' . $count_symbol;

	// Вернем дополненный массив Heartbeat нашими данными.
	return $response;
}

Теперь сервер вернет следующий набор данных:

{
	"myplugin_strlen":"Количество знаков в переданной фразе 9"
	"wp-auth-check":true,
	"server_time":1520878390
}

После того, как сервер вернул объект с данными, обработаем их:

<?php
add_action( 'admin_print_footer_scripts', function () {
	?>
	<script>
		jQuery( document ).on( 'heartbeat-tick', function ( event, data, textStatus, jqXHR ) {
			// event - объект события
			// data - приходящие данные
			// textStatus - статус выполнения запроса, к примеру success.
			// jqXHR - объект запроса
			console.log(event, data, textStatus, jqXHR);

			// Проверим, есть ли наши данные и если нет - остановим скрипт.
			if ( ! data.myplugin_strlen ) {
				return;
			}

			// Выведем данные на экран через alert()
			alert( data.myplugin_strlen );
		});
	</script>
	<?php
} );

Мы рассмотрели три этапа, но не каждая задача нуждается в первом или третьем этапе.

PHP функционал сосредоточен в файле ajax-actions.php.

Обрабатывает Heartbeat запросы от зарегистрированных пользователей. Прежде чем обработать запрос, проверяет наличие nonce кода. Если его нет - прерывает работу и возвращает ошибку. Если срок действия nonce кода истёк - возвращает информацию об этом и на странице появляется всплывающее окно с просьбой авторизоваться. Также в ответ добавляет время сервера функцией time().

Свой функционал можно добавить через фильтры и события. См. ниже.

Фильтрует полученные данные heartbeat. Срабатывает, если есть данные в $data.

$response(массив)
Данные для ответа Heartbeat.
$data(массив)
Присланные данные в массиве $_POST.
$screen_id(строка)
ID экрана. В php совпадает с $current_screen->id и c глобальным pagenow в JS.

Пример

add_filter( 'heartbeat_received', 'myplugin_receive_heartbeat', 10, 3 );

function myplugin_receive_heartbeat( $response, $data, $screen_id ) {
	// Если наши данные не пришли, то возвращаем оригинал ответа.
	if ( empty( $data['myplugin_field'] ) ) {
		return $response;
	}

	/**
	 * Обращаемся к нашим данным.
	 * Ключ массива совпадает с меткой (handle) в JS, которая была присвоена данным.
	 */
	$received_data = $data['myplugin_field'];

	// Пусть приходит строка и для примера посчитаем сколько в этой строке символов.
	$count_symbol = mb_strlen( $received_data );

	$response['myplugin_field_strlen'] = 'Количество знаков в переданной фразе ' . $count_symbol;

	// Вернем дополненный массив Heartbeat нашими данными.
	return $response;
}

Фильтрует данные для ответа Heartbeat.

$response(массив)
Данные для ответа Heartbeat.
$screen_id(строка)
ID экрана.

Пример из ядра WordPress

В ответ heartbeat добавляется информация о статусе авторизации пользователя.

add_filter( 'heartbeat_send', 'wp_auth_check' );

function wp_auth_check( $response ) {
	$response['wp-auth-check'] = is_user_logged_in() && empty( $GLOBALS['login_grace_period'] );
	return $response;
}

Простой пример:

Добавим в ответ heartbeat информацию о количестве непроверенных комментариев.

add_filter( 'heartbeat_send', 'myplugin_send_heartbeat', 10, 2 );

function myplugin_send_heartbeat( $response, $screen_id ) {
	$comments_count = wp_count_comments();
	$response['moderated'] = $comments_count->moderated;
	return $response;
}

Позволяет изменить опции в JS объекте wp.heartbeat.settings.

// Изменит дефолтный интервал опроса сервера с 60 секунд на 30.
add_filter( 'heartbeat_settings', function ( $settings ) { 
	$settings['mainInterval'] = 30;
	return $settings; 
} );

Срабатывает после формирования heartbeat данных, но перед тем, как сервер отдаст их клиенту. Это отличное место для реализации long-polling или других действий.

$response(массив)
Данные для ответа Heartbeat.
$screen_id(строка)
ID экрана.

Пример

Если пришли какие-то определенные данные, известить администратора письмом.

add_action( 'heartbeat_tick', 'myplugin_heartbeat_tick', 10, 2 );

function myplugin_heartbeat_tick( $response, $screen_id ) {
	if( ! empty($response['some_key']) ){
		wp_mail('[email protected]', 'Тема письма', 'Содержание письма');
	}
}

Для взаимодействия с неавторизованными пользователями во фронте, вместо функции wp_ajax_heartbeat() срабатывает функция wp_ajax_nopriv_heartbeat(). Она отличает от первой лишь тем, что не проверяет nonce код и статус авторизации, а также в её состав входят хуки с другими именами, но аналогичным поведением:

JavaScript функционал сосредоточен в файле heartbeat.js.

На этом событии можно добавить свои данные в коллекцию heartbeat перед отправкой их на сервер.

jQuery( document ).on( 'heartbeat-send', function ( event, data ) {
	// Добавляем данные в Heartbeat. Затем на сервере данные можно взять из массива по ключу myplugin_customfield.
	data.myplugin_customfield = 'some_data';
});

Срабатывает каждый раз, когда от сервера приходят heartbeat данные. На этом событии оперируем с присланными данными.

jQuery(document).on('heartbeat-tick', function (event, data, textStatus, jqXHR) {
	// event - объект события
	// data - приходящие данные
	// textStatus - статус выполнения запроса, к примеру success.
	// jqXHR - объект запроса
	console.log(event, data, textStatus, jqXHR);

	// Проверяем, пришли ли данные от нашего плагина/темы.
	if (!data.data.myplugin_responce_data) {
		return;
	}

	alert('Эти данные вернулись от моего плагина/темы: ' + data.myplugin_responce_data);
});

Также можно отследить событие для нужного ответа по метке данных:

$(document).on( 'heartbeat-tick.myplugin_responce_data', function( event, data, textStatus, jqXHR ) {
	// Код
});

Срабатывает каждый раз, когда запрос к серверу был провален, то есть сработало $.ajax().fail(). На этом событии можно отследить, какая именно произошла ошибка и сделать что-либо по необходимости.

jQuery( document ).on( 'heartbeat-error', function ( jqXHR, textStatus, error ) {
	// jqXHR - объект запроса.
	// textStatus - статус выполнения запроса.
	// error - текстовый вариант ошибки (может быть abort, timeout, error, parsererror, empty, unknown).
	console.log(jqXHR, textStatus, error);

	if( 'timeout' === error ){
		alert('Прошло 30 секунд, а сервер так и не ответил. И это печально!');
	}
});

Heartbeat API имеет в составе ещё несколько событий, но они используются при разработке крайне редко:

  • heartbeat-connection-lost
  • heartbeat-connection-restored
  • heartbeat-nonces-expired

Работа с событиями напрямую не единственный способ взаимодействовать с Heartbeat API. Дело в том, что сердцем механизма является класс Heartbeat(), экземпляр которого помещен в переменную window.wp.heartbeat и доступен для разработчиков при написании JavaScript кода.

Рассмотрим методы этого класса.

Добавляет данные в очередь для отправки в следующем XHR. Поскольку данные отправляются асинхронно, то эта функция не возвращает ответ XHR. Увидеть ответ можно на событии heartbeat-tick. Если одна и та же метка используется несколько раз, данные не перезаписываются, когда третий аргумент noOverwrite имеет значение true. Используйте wp.heartbeat.isQueued('handle'), чтобы увидеть, были ли какие-либо данные уже поставлены в очередь для этой метки.

handle(строка)
Уникальная метка (дескриптор) отправляемых данных. На бэкэнде используется как ключ массива для получения переданных значений.
data(любые)
Отправляемые данные.
noOverwrite(логический)
Перезаписывать ли существующие данные в очереди.

Возвращает true, если данные были поставлены в очередь и false, если нет.

// Добавим в очередь данные для отправки.
wp.heartbeat.enqueue(
	'my-plugin-data',
	{
		'param1': 'param value 1',
		'param2': 'param value 2',
	},
	false
);

Возвращает данные, стоящие в очереди, по их метке (Handle).

handle(строка)
Уникальная метка (дескриптор) отправляемых данных.
// Добавляем данные в очередь
wp.heartbeat.enqueue(
	'my-plugin',
	{
		'param1': 'value 1',
		'param2': 'value 2',
	},
	false
);

// Получим данные
var myData = wp.heartbeat.getQueuedItem('my-plugin')

console.log(myData);
/*
вернётся
{
	'param1': 'value 1',
	'param2': 'value 2',
}
/

Проверяет, находятся ли данные с определенной меткой в очереди.

handle(строка)
Уникальная метка (дескриптор) отправляемых данных.

Возвращает true, если данные есть в очереди и false, если нет.

// В консоли отобразится false
console.log( wp.heartbeat.isQueued('my-plugin') );

// Добавляем данные в очередь
wp.heartbeat.enqueue(
	'my-plugin',
	{
		'param1': 'value 1',
		'param2': 'value 2',
	},
	false
);

// В консоле отобразится true
console.log( wp.heartbeat.isQueued('my-plugin') ); // true

Удаляет из очереди данные по их метке (handle).

handle (строка)
Уникальная метка (дескриптор) отправляемых данных.
// Добавляем данные в очередь
wp.heartbeat.enqueue(
	'my-plugin',
	{
		'param1': 'value 1',
		'param2': 'value 2',
	},
	false
);

// В консоле отобразится true, данные 'my-plugin' в очереди имеются
console.log( wp.heartbeat.isQueued('my-plugin') ); // true

// Удалим данные из очереди
wp.heartbeat.dequeue('my-plugin');

// В консоле отобразится false, данные 'my-plugin' в очереди отсутствуют
console.log( wp.heartbeat.isQueued('my-plugin') ); // false

Устанавливает или возвращает интервал опроса сервера в секундах.

speed(строка/число)
Скорость опроса в секундах. Может быть 'fast' или 5, 15, 30, 60, 120, 'long-polling' (экспериментально). Если окно не в фокусе, интервал замедляется до 2 минут.
По умолчанию: 60
ticks(число)
Данный аргумент применяется, если аргумент speed равен 'fast' или 5. Позволяет указать сколько раз посылать запросы с этим интервалом. Можно указать не более 30, то есть максимум каждые 5 секунд сервер будет опрашиваться в течение 2 минут 30 секунд. После этого speed возвращается к значению по умолчанию, то есть 60 секунд.
По умолчанию: 30
// В консоли отобразится 60 (значение по умолчанию)
console.log( wp.heartbeat.interval() ); // 60

wp.heartbeat.interval(30);

// В консоле отобразится 30
console.log( wp.heartbeat.interval() ); // 30

Проверяет имеет ли окно (или любой локальный iframe в нем) фокус или активен ли пользователь. Возвращает true или false. Обращается к свойству settings.hasFocus. Работает на основе нативного document.hasFocus() (если доступен и обновляет settings.hasFocus по таймеру каждые 10 секунд). Если нужно проверять фокус чаще, чем каждые 10 секунд, используйте document.hasFocus().

Ниже код каждые 5 секунд будет проверять, активна ли вкладка в браузере, где используется heartbeat, и выводить в консоль браузера соответствующее сообщение.

setInterval(function () {
	if (wp.heartbeat.hasFocus()) {
		console.log('Приложение в фокусе.');
	} else {
		console.log('Фокус потерян. Вы свернули браузер, переключись на другую вкладку или приложение.');
	}
}, 5000);

Отключает приостановку. Следует использовать только в том случае, когда heartbeat выполняет такие важные задачи, как автосохранение, пост-блокировка и т.д. Использование этого функционала на многих экранах может привести к перегрузке учетной записи хостинга пользователя, если несколько окон/вкладок браузера остаются открытыми в течение длительного времени.

Сразу отправляет heartbeat запрос вне зависимости от состояния hasFocus. Не будет открывать два одновременных соединения. Если подключение выполняется, будет подключен снова сразу после завершения текущего подключения. Особенно удобен при тестировании heartbeat функционала, так как можно сделать запрос сразу, в том числе и из консоли браузера.

Проверяет, существует ли ошибка подключения.

if (wp.heartbeat.hasConnectionError()) {
	console.log('Последний запрос был завершен с ошибкой.');
} else {
	console.log('Ошибок нет.');
}

Нет ничего лучше, чем примеры в виде реальных плагинов, которые всегда можно найти в репозитории WordPress.

Данный плагин, как пример, при heartbeat запросе собирает данные о комментариях, ожидающих модерации, и обновляет счётчики в сайдбаре и админбаре. Срабатывает, как в админке, так и во фронте. И только у тех пользователей, у которых есть права модерировать комментарии.

Состоит из двух файлов.

admin-comment-notice.php

add_action( 'wp_enqueue_scripts', 'acn_enqueue_scripts' );
add_action( 'admin_enqueue_scripts', 'acn_enqueue_scripts' );
add_filter( 'heartbeat_send', 'acn_heartbeat_send' );

/**
 * Добавляет данные в heartbeat ответ.
 *
 * @param array $response
 *
 * @return array
 */
function acn_heartbeat_send( $response ) {
	if ( ! current_user_can( 'moderate_comments' ) ) {
		return $response;
	}

	$count = wp_count_comments();
	$count = absint( $count->moderated );
	$i18n  = number_format_i18n( $count );

	// Админ-сайдбар
	$menu = '<span class="awaiting-mod count-' . $count . '"><span class="pending-count">' . $i18n . '</span></span>';
	$menu = sprintf( __( 'Comments %s' ), $menu );

	// Админ-бар
	$text = sprintf( _n( '%s comment awaiting moderation', '%s comments awaiting moderation', $count ), $i18n );
	$bar  = '<span class="ab-icon"></span>';
	$bar  .= '<span class="ab-label awaiting-mod pending-count count-' . $count . '" aria-hidden="true">' . $i18n . '</span>';
	$bar  .= '<span class="screen-reader-text">' . $text . '</span>';

	// Данные
	$response['acn'] = array(
		'menu'  => $menu,
		'bar'   => $bar,
		'count' => $i18n,
	);

	return $response;
}

/**
 * Подключает скрипт плагина.
 */
function acn_enqueue_scripts() {
	if ( is_admin_bar_showing() && current_user_can( 'moderate_comments' ) ) {
		$script_url = plugins_url( 'scripts.js', __FILE__ );
		wp_enqueue_script( 'acn-script', $script_url, array( 'heartbeat' ) );
	}
}

scripts.js

jQuery(document).on('heartbeat-tick', function (event, data) {

	// Проверим, есть ли наши данные и если нет - остановим скрипт.
	if (data.acn === undefined) {
		return;
	}

	// Находим контейнеры, в которых будем менять содержимое.
	var $menu = jQuery('#menu-comments').find('.wp-menu-name');
	var $bar = jQuery('#wp-admin-bar-comments').find('a');

	// Изменяем содержимое контейнеров.
	jQuery($menu).html(data.acn.menu);
	jQuery($bar).html(data.acn.bar);
});

При создании статьи использовались материалы: