Шаблон подписки-публикации для начинающих фронтендеров

Разбираемся в асинхронном JavaScript-коде и изучаем шаблон подписки-публикации – один из самых популярных в веб-разработке.

В любом проекте рано или поздно наступает момент, когда вы заканчиваете работать со стилями, эстетикой пользовательского интерфейса и системой сеток и перемещаете фокус своего внимания на логику, фреймворки и написание JavaScript-кода. Тут-то все и начинается...

Вы видите, что дело не ограничивается парой jQuery-трюков и создаете целое веб-приложение, а не просто страничку в интернете.

Вы прилагаете усилия, думаете об интерактивности, логике и подсистемах. Ваш код работает, а приложение оживает.

Вы решаете одни проблемы – появляются другие.

Вы решаете и их тоже, пробуете новые техники и подходы. Кода становится все больше, и вам уже немного не по себе.

Файл script.js растет. Час назад было всего 200 строк, сейчас уже больше 500. Ничего страшного, ведь вы читали о чистом и поддерживаемом коде и можете разделить всю логику на модули, блоки и компоненты. Ваши файлы размещены по каталогам, правильно названы, красивы, их легко поддерживать. Но вам по-прежнему не по себе.

Зуд разработчика

Поведение веб-приложений очень редко бывает линейным. В большинстве случаев действия в них происходят внезапно – события сети, клики пользователей, механизмы синхронизации, отложенные обработчики – на это все нужно правильно реагировать. Уродливые асинхронные монстры стучатся в ваши двери.

Теперь становится понятно, почему вам не по себе – ведь вы не знаете, куда положить все эти куски кода!

Да, ваше приложение аккуратно разделено на блоки, которые размещены там, где нужно. Все управляется из точки старта – файла app.js. Но вам нужно запустить асинхронный код в одной части приложения, обработать его и отправить в другую часть.

Следует ли разместить его в компоненте пользовательского интерфейса? Или в главном файле? Какой блок вашего приложения должен отвечать за асинхронную обработку? А за обработку ошибок?

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

Расслабьтесь, с вами все в порядке. Чем более структурировано ваше мышление, тем чаще и сильнее будет этот зуд разработчика.

Поиск решения

Вы начинаете читать о вашей проблеме и искать решения. Узнаете о промисах и их преимуществах перед коллбэками. Целый час пытаетесь понять, что такое RxJS, и почему какой-то случайный парень на форуме говорит, что это единственное спасение для интернета. Ищете разницу между redux-thunk и redux-saga.

Ваша голова гудит, а мозг взрывается от огромного количества возможных подходов. Почему же их так много? Разве это не должно быть просто? Зачем люди спорят в интернете вместо того, чтобы разработать один хороший шаблон?

Эта тема совсем не так проста, как кажется.

Какую бы платформу вы ни использовали, правильное расположение асинхронного кода никогда не будет очевидным. Не существует универсального решения – оно всегда зависит от особенностей конкретного проекта.

Волшебной пилюли не будет, однако мы можем разобраться в основном принципе и сделать асинхронный код немного проще.

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

Под капотом все асинхронные хитрости (redux-saga, RxJS, observables и миллионы других) опираются на одни и те же принципы.

Давайте сделаем (и сломаем) что-нибудь

Возьмем очень простое приложение, отмечающее любимые места на карте.

Ничего особенного: справа сама карта, слева – боковая панель. Клик по карте создает и сохраняет новый маркер.

Конечно, мы хотим расширить функциональность и добавить новую фичу: приложение должно запоминать выбранные места с помощью локального хранилища.

Схема работы примерно такая:

Выглядит совсем несложно.

Все примеры для краткости будут написаны на ванильном JavaScript без использования фреймворков и библиотек. Кроме того, будут использованы некоторые методы Google Maps API.

Давайте приступим непосредственно к кодированию и набросаем прототип:

let googleMap;
let mapPlace = [];

function init() {
	googleMap = new google.maps.Map(document.getElementById('map'), {
		center: { lat: 0, lng: 0 }, zoom: 3
	});

	googleMap.marketList = [];
	googleMap.addListener('click', addPlace);

	const placesFromLocalStorage = JSON.parse(localStorage.getItem('myPlaces'));

	// если в локальном хранилище что-то нашлось
	// записать это в текущий список мест
	if (Array.isArray(placesFromLocalStorage)) {
		myPlaces = placesFromLocalStorage;           
		renderMarkers();                           
	}
}

function addPlace(event) {
	myPlaces.push({
		position: event.latLng
	});

	// после добавления маркера
	// отрендерить его
	// и синхронизировать локальное хранилище
	localStorage.setItem('myPlaces', JSON.stringify(myPlaces));
	renderMarkers();
}

function renderMarkers() {
	// удалить все маркеры
	googleMap.markerList.forEach(m => m.setMap(null)); 

	// добавить новые маркеры
	// из массива myPlaces
	myPlaces.forEach((place) => {
		const marker = new google.maps.Marker({
			position: place.position,
			map: googleMap
		});

		googleMap.markerList.push(marker);
	})
}

init();
  • функция init() инициализирует карту с помощью Google Maps API, настраивает реакцию на клики и пытается загрузить маркеры из localStorage.
  • addPlace() обрабатывает щелчки мыши по карте: добавляет новое место в список и вызывает отрисовку маркеров.
  • renderMarkers() перебирает все элементы массива и после очистки карты помещает на нее маркеры.

Если отбросить некоторые недостатки этого кода (monkey patching, отсутствие обработки ошибок), то он может послужить хорошим аккуратным прототипом.

Создадим разметку:

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<title>My Favorite Places</title>
	<link rel="stylesheet" href="/style.css">
</head>
<body>
	<div class="sidebar">
		<h1>My fav places on earth v1.0</h1>
	</div>
	<div class="main-content-area">
		<div id="map"></div>
	</div>
	<script src="https://maps.googleapis.com/maps/api/js?key=API_KEY_HERE"></script>
	<script src="map.js"></script>
</body>
</html>

Стили писать не будем, просто предположим, что они уже есть.

Критический взгляд

Выглядит страшненько, но работает. Однако это приложение невозможно масштабировать.

Во-первых, мы смешиваем обязанности. Вы наверняка знаете о SOLID-принципах программирования, и первый из них – принцип единственной ответственности, который здесь нарушен. Один файл заботится и об обработке пользовательских действий, и о работе с данными, так не должно быть.

"Но ведь этот код работает!", – можете возмутиться вы. Несомненно, но поддерживать его трудно. Представьте, что вы решили расширить приложение новыми функциями:

Во-первых, список отмеченных мест должен выводиться на боковой панели. Во-вторых, нужна возможность искать названия городов с помощью Google API – и здесь в игру вступают асинхронные механизмы.

Новая схема будет примерно такой:

Получение города не происходит моментально, требуется некоторое время, чтобы запрос выполнился.

Разделяй и властвуй

Вернемся к пользовательскому интерфейсу, который состоит из двух отдельных частей: боковой панели и основной области. Определенно не следует обрабатывать и то, и другое в одном куске кода. Что, если в будущем приложение будет состоять не из двух, а из четырех компонентов? Или шести… Делим код на части – один файл для боковой панели, другой – для карты.

Какая же часть должна хранить массив отмеченных мест?

На самом деле, оба эти подхода неверны. В соответствии с принципом единственной ответственности следует выделить данные в отдельный модуль.

Святой Грааль разделения кода – вынесение хранения и логики данных в отдельный файл – сервис, который будет отвечать за такие проблемы, как синхронизация с локальным хранилищем. А компоненты будут работать только как части пользовательского интерфейса.

Код сервиса данных:

let myPlaces = [];
const geocoder = new google.maps.Geocoder;

export function addPlace(latLng) {
	// вызов Google API для поиска города
	// второй параметр - функция обратного вызова
	geocoder.geocode({ 'location': latLng }, function(results) {
		try {
			// извлечь название города из результатов
			const cityName = results
				.find(result => result.types.includes('locality'))
				.address_components[0]
				.long_name;

			// добавить его в коллекцию
			myPlaces.push({ position: latLng, name: cityName });

			localStorage.setItem('myPlaces', JSON.stringify(myPlaces));
		} catch(e) {
			// если город не найден, вывести сообщение
			console.log('No city found in this location! :(');
		}
	});
}

// функция, которая возвращает текущий список мест
export function getPlaces() {
	return myPlaces;
}

// функция, которая пытается получить список из локального хранилища
function initLocalStorage() {
	const placesFromLocalStorage = JSON.parse(localStorage.getItem('myPlaces'));
	if (Array.isArray(placesFromLocalStorage)) {
		myPlaces = placesFromLocalStorage;           
		publish();                           
	}
}

initLocalStorage();

Компонент карты:

let googleMap;

import { addPlace, getPlaces } from './dataService.js';

function init() {
	googleMap = new google.maps.Map(document.getElementById('map'), {
		center: { lat: 0, lng: 0 }, zoom: 3
	});

	googleMap.markerList = [];
	googleMap.addListener('click', addMarker);
}

function addMarker(event) {
	addPlace(event.latLng);
	renderMarkers();
}

function renderMarkers() {
	googleMap.markerList.forEach(m => m.setMap(null));

	getPlaces().forEach((place) => {
		const marker = new google.maps.Marker({
			position: place.position,
			map: googleMap
		});

		googleMap.markerList.push(marker);
	})
}

init();

Компонент боковой панели:

import { getPlaces } from './dataService.js';

function renderCities() {
	// получить dom-элемент для рендеринга
	const cityListElement = document.getElementById('citiesList');

	// очистить его
	cityListElement.innerHTML = '';

	// и заполнить данными
	getPlaces().forEach((place) => {
		const cityElement = document.createElement('div');
		cityElement.innerText = place.name;
		cityListElement.appendChild(cityElement);
	});
}

renderCities();

Кажется, зуд разработчика стал тише. Код аккуратно организован в соответствии с лучшими практиками. Однако прежде чем вздохнуть с облегчением, давайте запустим приложение.

Проблема интерфейса

…ой! Интерфейс не реагирует на действия пользователя, ведь мы не реализовали никаких средств синхронизации. Приложение не знает, когда добавляется новое место, и мы даже не можем поместить метод getPlaces() после вызова addPlace(), потому что поиск города асинхронен и занимает некоторое время.

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

setInterval(() => {
    renderCities();
}, 1000);

Это довольно дерганное, но все же вполне рабочее решение. Однако оно не оптимально. Мы наводняем цикл событий действиями, которые в подавляющем большинстве случаев абсолютно не нужны.

В конце концов, вы не посещаете местное почтовое отделение каждый час, чтобы проверить, пришла ли вам посылка. А если вы оставляете ваш автомобиль на ремонт, то не звоните механику каждые полчаса (по крайней мере, мы в это верим). Вместо этого вы ждете телефонного звонка. А как механик узнает, куда звонить? Разумеется, вы оставили ему свои контактные данные.

Оставляем номер телефона

В JavaScript мы можем поступить таким же образом.

В этом языке функции ведут себя как любые другие значения, в том числе их можно помещать в переменные или передавать в качестве параметра в другие функции. Вы уже знаете, как работает этот механизм: вспомните setTimeout/setInterval и слушатели событий.

Это фундамент для всех асинхронных сценариев.

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

Другими словами, мы можем взять метод renderCities и передать его в dataService. Там он будет вызываться при необходимости, ведь сервис точно знает, когда следует обновить данные в компонентах.

Начнем с того, что научим сервис запоминать и вызывать нужные функции:

Теперь подпишем боковую панель на изменения:

Код компонента боковой панели регистрирует метод renderCities в службе dataService.

Затем dataService вызывает этот метод, если данные изменяются (в результате вызова метода addPlace()).

Другими словами, одна часть кода является ПОДПИСЧИКОМ события (компонент боковой панели), а другая – ИЗДАТЕЛЕМ (сервис данных). Таким образом, мы реализовали базовую форму шаблона подписки-публикации, на котором основаны практически все асинхронные концепции.

Улучшение шаблона подписки-публикации

Обратите внимание, что мы ограничились только одним подписчиком. Если в метод subscribe будет передана еще одна функция, она перезапишет текущую. Чтобы решить эту проблему, мы создадим массив подписчиков:

let changeListeners = [];

export function subscribe(callbackFunction) {
  changeListeners.push(callbackFunction);
}

Теперь напишем функцию, которая будет вызывать их всех.

Теперь можно подключить и компонент map.js:

import { addPlace, getPlaces, subscribe } from './dataService.js';

let googleMap;
/* ... */

init();
renderMarkers();

subscribe(renderMarkers);

function renderMarkers() {
 /* ... */

Кроме того, в подписчики можно напрямую передавать данные, например, массив маркеров:

И затем обрабатывать их в компоненте:

Здесь очень много возможностей, например, можно создавать разные каналы для разных действий. Также можно извлечь методы публикации и подписки и выделить их в отдельный модуль.

Это видео демонстрирует работу только что созданного приложения:

А код можно найти в github-репозитории.

Хорошо знакомый паттерн

Вы видите нечто знакомое в этом шаблоне подписки-публикации? Это тот же самый механизм, который используется в методе element.addEventListener(action, callback). Вы подписываете свою функцию на определенное событие, которое вызывается при публикации элементом некоторого действия.

Почему так важно понимать работу шаблона подписки-публикации? Ведь в долгосрочной перспективе нет смысла вручную синхронизировать все изменения данных на ванильном JavaScript. В разных фреймворках есть различные устоявшиеся решения этой проблемы: Angular использует RxJS, в React есть Redux и так далее. Однако все это – просто вариации шаблона подписки-публикации.

Прослушиватели DOM-событий – это подписка на публикацию действий пользовательского интерфейса. Промисы – это подписка на выполнение определенного отложенного действия. Механизмы обновления компонентов в React – подписка. Метод on() веб-сокетов, Fetch API, Redux, RxJS – все это подписка. Под капотом нет никакой магии – принцип один и тот же.

Независимо от того, какой метод решения асинхронной задачи вы используете, это всегда некоторая вариация одного и того же принципа: что-то подписывается, что-то публикует.

В этой статье не были затронуты некоторые важные моменты шаблона подписки-публикации:

  • Механизмы отписки слушателей, когда они больше не нужны;
  • Множественные подписки (например, addEventListener позволяет подписаться на различные события);
  • Расширенные идеи, например, шины событий.

Эти JavaScript-библиотеки реализуют шаблон подписки-публикации в чистом виде:

Попробуйте поэкспериментировать с ними, узнайте, что происходит под капотом.

А вот две замечательных статьи на эту же тему:

Перевод статьи Why every beginner front-end developer should know publish-subscribe pattern?

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ