Блог Андрея

 
 

Кэширование с помощью service worker - ещё один мануал. Если вы до сих пор не разобрались - вам сюда.

Статья адресована всем фронтендерам пишущим на javascript, кто всё ещё неуверенно чувствует себя, когда дело касается кэширования через service worker, хочет уметь писать этот функционал с чистого листа без шпаргалки, тем кто уже читал например вот эту статью и MDN, но пока не может с чистой совестью сказать, что он досконально разобрался.

Не подумайте, что я критикую упомянутую статью (не говоря уже о MDN) - без неё мне было бы много тяжелее. Можете сначала прочесть её - возможно вам этого хватит.

Однако, после прочтения я ещё довольно долго разбирался - мне хотелось научиться писать реализацию кэширования через service worker так же легко, как я могу написать код, который заменит текст в первом параграфе этой статьи на «Hello world» по нажатию на кнопку.

Мне было трудно оттого, что я очень мало работал ранее с js воркерами вообще и с service worker-ами в частности, а также с Promise.

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

Что я хочу получить

Хочу получить кэширование вида «Если есть в кэше - вернуть данные из кэша, иначе вернуть с сервера. Когда запрос обработан таким образом, в фоне обновить данные в кэше».

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

Эту стратегию я назову if-cache-else-network-pause-update-cache.

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

Реализация описанная в этой статье будет иметь один явный недостаток - при самом первом посещении страницы ничего кэшироваться не будет, это будет исправлено позже. Дело в том, что объём информации показался мне настолько большим, что его лучше разбить на две статьи, для более простого усвоения.

Требования для работы

  • Сервер с нормальным "зелёным" SSL сертификатом. Если у вас на хостинг и ssl сертификат нет денег или желания, используйте бесплатный хостинг на heroku или github, он вполне годится. На бесплатных сайтах от 000webhost ssl сертификат плохой, лучше бы его там не было.
  • Файл с кодом service worker должен быть в корне сайта (особенно если вы собираетесь кэшировать с его помощью все страницы сайта.
  • Код регистрации сервис-воркера не должен отрабатывать по DOMReady или window.onload. Он должен отработать как можно раньше, пусть он даже будет инлайновым в коде страницы - это не тот случай когда это страшно плохо. Иначе может не работать кэширование первого запроса (обычно это html страницы которую вы загружаете), что совсем не годится например для progressive web applications.

Общие сведения о service worker

Service worker - это javascript который работает в фоне. Он продолжает работать даже когда вы закрыли все вкладки с страницами сайта (но не браузер).

Для подключения service worker вы должны зарегистрировать его, для этого существует отдельный скрипт, он может быть инлайновым, в этом нет ничего страшного.

Скрипт с Service Worker имеет свойство кэшироваться и его код обновляется по немного непредсказуемым правилам (заявлено, что раз в 24 часа, но бывает и иначе, сам видел). Поэтому писать и отлаживать код стоит в отдельном профиле Firefox или Chrome, очищая данные с помощью Shift+Ctrl+Delete (а вот жать F5 после этого не стоит, лучше предварительно скопировать url в новую вкладку, предварительно закрыв старую вкладку и нажать Enter после Shift+Ctrl+Delete).

Скрипт с Service Worker может сообщаться с обычными скриптами javascript на страницах сайта путем отправки и получения postMessage.

Для реализации кэширования вам понадобиться в коде service worker определить слушатели событий installl, activate, fetch, message (для описанной в этой статье реализации мне понадобились только activate и fetch).

«Перехватывать» запросы к серверу вы может в слушателе fetch (я традиционно назвал его onFetch).

Более подробно о Service Worker

Общие сведения о Promise

Promise - это стандартный объект в javascript (то есть такой же как String или Array) версии es5 и выше.

Код с Promise немного напоминает код отслеживания события, которое может завершиться успешно или неуспешно, но это не одно и то же. Тем не менее, мы можем передать аналог слушателя onSuccess в метод Promise.then() и аналог слушателя onFail в метод Promise.catch() и работать почти также, как с событийным кодом.

Promise после того, как отработали «аналог слушателя onSuccess в методе Promise.then()» или «аналог слушателя onFail в методе Promise.catch()» «разрешается» тем значением, которое вернул «аналог слушателя». Это значит, что вам может показаться, что ваша функция, возвращающая Promise вернула то значение, которое вернул onFoundResInCache или анонимная функция в catch из примера ниже.


//cache.match возвращает экземпляр Promise
return cache.match(request)
	//"Обработчик успеха" onFoundResInCache
	.then(onFoundResInCache)
	//"Обработчик неуспеха"  - анонимная функция
	//Если не найдено, запросим методом update и вернем результат, который вернет update
	.catch(() => { 
		if (self.verbose) console.log('No match, will run update');
		//а вот тут отличие от событийного кода!
		//update что-то возвращает и если отработает данная анонимная функция,
		// то cache.match в итоге "разрешится" именно им!
		return update(cache, request); 
	});

Из функции, передаваемой в Promise.then() можно вернуть Promise.reject(String s), тогда отработает и функция, передаваемая в Promise.catch().


/**
 * @description Обработка "события" "Найдено в кэше"
 * @param {Response} result
 */
function onFoundResInCache(result) {
	if (self.verbose) console.log('found in cache!3..', result);
	//если не найдено, вернем Promise.reject - благодаря этому в onOpenCacheForSearchRequest вызовется catch
	if (!result || String(result) == 'undefined') {
		if (self.verbose) console.log('will return no-match Promise');
		return Promise.reject('no-match');
	}
	if (self.verbose) console.log('will return result OR no-match Promise');/**/
	//Вобщем-то можно сократить до этой строчки, как и было у автора
	return (result || Promise.reject('no-match'));
}

Более подробно о Promise

Верхний уровень реализации


self.addEventListener('install', onInstall);
self.addEventListener('activate', onActivate);
self.addEventListener('fetch', onFetch);
self.addEventListener('message', onPostMessage);


function onInstall() {
	consoloe.log('I install');//Просто, чтобы вы могли видеть, что sw установился
}

function onPostMessage(objMessage) {
	//Тут например можно сменить интервал планового обновления (его проще конфигурировать во внешнем файле чем в коде sw)
}

/**
 * @description Обработка события активации
 */
function onActivate(){
	//Сообщим всем клиентам (клиенты - это например открытые вкладки с разными страницами вашего сайта в браузере)
	// сообщим, что мы тут и работаем.
	self.clients.claim();
}

/**
 * @description Перехватываем запрос
*/
function onFetch(event) {
	
	//Обратимся за ответом на запрос в кэш, а если него там нет, то на сервер
	//Всю логику, которая ищет данные сначала в кэше, а потом на сервере опишем в  функции getResponseFromCacheOrNetwork
	event.respondWith(getResponseFromCacheOrNetwork(event.request) );
	
	//Чтобы не DDOS-ить сервер одинаковыми запросами с малым промежутком при первом открытии страницы, сделаем секундную паузу перед тем как обновить данные в кэше
	//Клонируем запрос, потому что его на момент вызова лямбды может и не существовать
	let req = event.request.clone();
	setTimeout(() => {
		//Откроем кэш и вызовем нашу функцию update
		caches.open(CACHE).then((cache) => {
			if (self.verbose)  console.log('Schedule update  ' + req.url);
			update(cache, req);//Здесь будет логика отправки запроса на сервер
		});
	}, 1000);
}
Скрипт должен открываться по адресу https://your.site/sw01.js. Не надо запихивать его во внутренние папки, добавлять в сборку через webpack и так далее.

Разберём верхний уровень реализации. Обработку onInstall я оставил только для того, чтобы убедиться что установка воркера прошла успешно (в следующей статье я задействую его более основательно). Если вы впервые занимаетесь с этим видом javascript, вам это тоже может оказаться не лишним. Следует иметь ввиду, что событие наступает один раз. То есть вы увидите в консоли эту строку только при самой первой загрузке страницы сайта. Именно поэтому я советовал работать в отдельном профиле Firefox или Chrome - сбросьте данные профиля полностью через Shift+Ctrl+Delete - тогда вы по этому сообщению сможете отследить, что воркер переустановился.

onPostMessage я пока на самом деле не использую, но планирую. Так как файл с кодом service worker кешируется по своим не совсем понятным правилам, его удобно конфигурировать из обычного javascript скрипта, кэшированием котрого мы этим самым service worker можем управлять. На продакшене мы сможем например передавать из него время паузы перед началом обновления данных в кэше или даже периодичность такого обновления. Также, мы сможем принимать список url, которые необходимо добавить в кэш при самом первом посещении страницы пользователем.

onActivate. Цитата из MDN: «...cуществующие страницы могут быть переведены под контроль активного воркера с помощью Clients.claim().» То есть, сообщили всем открытым во вкладках и окнах браузера страницам сайта, что они теперь под нашим контролем.

Ну а далее самый интересный для данной статьи обработчик - onFetch. Первое, что про него надо понять - он будет вызываться всегда: и когда браузер запросит какой-то ресурс с сайта, и когда вы в коде service worker будете получать данные с сервера вызывая self.fetch(request);

В коде onFetch сейчас описано два действия, вот первое (пытаемся взять данные из кэша, если их там нет получаем с сервера и возвращаем):


//Обратимся за ответом на запрос в кэш, а если него там нет, то на сервер
//Всю логику, которая ищет данные сначала в кэше, а потом на сервере опишем
// в функции getResponseFromCacheOrNetwork
event.respondWith(getResponseFromCacheOrNetwork(event.request) );

Вот второе (обновляем данные в кэше):


//Чтобы не DDOS-ить сервер одинаковыми запросами с малым промежутком при первом открытии
// страницы, сделаем секундную паузу перед тем как обновить данные в кэше
//Клонируем запрос, потому что его на момент вызова лямбды может и не существовать
let req = event.request.clone();
setTimeout(() => {
	//Откроем кэш и вызовем нашу функцию update
	caches.open(CACHE).then((cache) => {
		if (self.verbose)  console.log('Schedule update  ' + req.url);
		update(cache, req);//Здесь будет логика отправки запроса на сервер
	});
}, 1000);

Очевидно, что если оставить этот код без изменения, перехват запросов будет глючить: не найдя в кэше результата, метод getResponseFromCacheOrNetwork будет запрашивать его на сервере, при этом снова будет вызываться onFetch и так до бесконечности (браузеры это дело обычно пресекают, но всё равно, так делать не надо).

Поэтому немного дополним наш верхний уровень реализации:


self.addEventListener('install', onInstall);
self.addEventListener('activate', onActivate);
self.addEventListener('fetch', onFetch);
self.addEventListener('message', onPostMessage);

/**
 * @description Здесь будем хранить url которые не надо искать в кэше (
 * это бывает нужно, когда в кэше уже искали, но его там нет)
 * То есть, сюда помещаем те url, которые не надо искать в кэше
*/
self.excludeUrlList = {};

self.verbose = false;

function onInstall() {
	consoloe.log('I install');//Просто, чтобы вы могли видеть, что sw установился
}

function onPostMessage(objMessage) {
	//Тут например можно сменить интервал планового обновления (его проще конфигурировать во внешнем файле чем в коде sw)
}

/**
 * @description Обработка события активации
 */
function onActivate(){
	//Сообщим всем клиентам (клиенты - это например открытые вкладки с разными страницами вашего сайта в браузере)
	// сообщим, что мы тут и работаем.
	self.clients.claim();
}

/**
 * @description Перехватываем запрос
*/
function onFetch(event) {
	//Если его не нашли в кэше, значит надо отправить запрос на сервер, то есть кормить собак и ничего не трогать
	if (self.excludeUrlList[event.request.url]) {
			if (self.verbose) console.log('Skip search in cache ' + event.request.url);
			return;
	}
	
	//Обратимся за ответом на запрос в кэш, а если него там нет, то на сервер
	//Всю логику, которая ищет данные сначала в кэше, а потом на сервере опишем в  функции getResponseFromCacheOrNetwork
	event.respondWith(getResponseFromCacheOrNetwork(event.request) );
	
	//Чтобы не DDOS-ить сервер одинаковыми запросами с малым промежутком при первом открытии страницы, сделаем секундную паузу перед тем как обновить данные в кэше
	//Клонируем запрос, потому что его на момент вызова лямбды может и не существовать
	let req = event.request.clone();
	setTimeout(() => {
		//Откроем кэш и вызовем нашу функцию update
		caches.open(CACHE).then((cache) => {
			if (self.verbose)  console.log('Schedule update  ' + req.url);
			update(cache, req);//Здесь будет логика отправки запроса на сервер
		});
	}, 1000);
}

Объект excludeUrlList буду заполнять непосредственно перед отправкой запроса на сервер и обнулять, когда получен или не получен ответ. Всё это будет происходить в функции update, которая будет использоваться не только в коде onFetch, но и в коде getResponseFromCacheOrNetwork.

Рассмотрим «второе действие» в onFetch (обновление кэша). В принципе, можно было бы не использовать setTimeout, но при изучении более наглядно видеть в консоли вывод лога обновления после того, как уже выведен лог работы «первого действия» getResponseFromCacheOrNetwork.

К тому же, если мы не используем setTimeout, то при первом запросе страницы пользователем, когда в кэше ещё ничего нет, будут отправлены почти одновремено одинаковые запросы (первый из getResponseFromCacheOrNetwork, второй из update). Это неестественное поведение клиента, а вот обновление страницы через секунду - в этом ничего необычного нет. Значит, меньше шанс попасть под какие-нибудь фильтры.

Что у меня внутри setTimeout. Я открываю свой кэш, а когда он открыт, «что-то делаю» (вызываю функцию update). Перед тем, как начать как-то работать с кэшем, его надо открыть.

Нам осталось рассмотреть реализацию getResponseFromCacheOrNetwork и update. Перед этим хочется сформулировать алгоритм этих действий.

Алгоритм получения данных из кэша

1 Мы должны открыть кэш
 
2 Когда кэш открыт, должны начать в нем поиск (тут в catch запрос обновления)
 
3 Когда поиск завершен, должны проверить валидность результата
 
4 Если результат невалиден, вернуть Promise.reject, это запустит код в catch из пункта 2 (если валиден, вернуть результат)
 

В соответствии с алгоритмом, код getResponseFromCacheOrNetwork выглядит так:


/**
 * @description Обратимся за ответом на запрос в кэш, а если него там нет, то на сервер
 * @param {Request} request
 */
function getResponseFromCacheOrNetwork(request) {
	return caches.open(CACHE).then((cache) => {
		return onOpenCacheForSearchRequest(cache, request);
	});
}

В методе мы открываем кэш и назначаем ему обработчик «Когда кэш открыт для поиска в нем результатов» onOpenCacheForSearchRequest. Этот метод по сути реализует шаг 2 алгоритма.


/**
 * @description Обработка события "Когда кэш открыт для поиска результата"
 * @param {Cache} cache Объект открытого кэша
 * @param {Request} request запрос, который будем искать
 */
function onOpenCacheForSearchRequest(cache, request) {
	//Ищем, если найдено, вернем результат onFoundResInCache
	return cache.match(request).then(onFoundResInCache)
	//Если не найдено, запросим методом update и вернем результат, который вернет update
		.catch(() => { 
			if (self.verbose) console.log('No match, will run update');
			return update(cache, request); 
		});
}

Тут всё просто, ищем, если не находим запускаем update(cache, request) и возвращаем результат его работы. update сделает запрос на сервер, запишет результат запроса в кэш и вернет результат.

Строка кода cache.match(request).then(onFoundResInCache) реализует «запуск» поиска и заодно назначает функцию контроля результата поиска onFoundResInCache.

onFoundResInCache реализует третий и четвертый пункты «Когда поиск завершен, должны проверить валидность результата».


/**
 * @description Обработка события "Найдено в кэше"
 * @param {Response} result
 */
function onFoundResInCache(result) {
	if (self.verbose) console.log('found in cache!3..', result);
	//если не найдено, вернем Promise.reject - благодаря этому в onOpenCacheForSearchRequest вызовется catch
	if (!result || String(result) == 'undefined') {
		if (self.verbose) console.log('will return no-match Promise');
		return Promise.reject('no-match');
	}
	if (self.verbose) console.log('will return result OR no-match Promise');/**/
	//Вобщем-то можно сократить до этой строчки, как и было у автора
	return (result || Promise.reject('no-match'));
}

Остаётся разобрать только метод update.

Алгоритм отправки запроса

Отправить запрос методом self.fetch
 
Когда получены валидные данные, клонировать response и записать в кэш методом put
 
Вернуть response
 

/**
 * @description Запрос данных с сервера. Этот метод вызывать в onOpenCache... ,
 * когда доступен объект открытого кэша cache
 * @param {Cache} cache - кеш, в котором ищем, на момент вызова должен уже быть открыт
 * @param {Request} request
 * @return Promise -> HttpResponse данные с сервера
*/
function update(cache, request) {
	if (self.verbose) console.log('Call update 2 ' + request.url);
	//Помечаем, что в onFetch не надо лезть в кэш за данным запросом
	self.excludeUrlList[request.url] = 1;
	//Собственно, запрос
	return fetch(request)
	//когда пришли данные
	.then((response) => {
		if (self.verbose) console.log('Got response ');
		//если статус ответа 200, сохраним ответ в кэше
		if (response.status == 200) {
			cache.put(request, response.clone() );
			//Помечаем, что эти данные уже есть в кэше
			self.excludeUrlList[request.url] = 0;
		}
		//вернем ответ сервера
		return response;
	})
	//Сервер не ответил, например связь оборвалась
	.catch((err) => {
		//Если с сервера ничего полезного не пришло, а в кэше у нас тоже ничего нет,
		//  всё печально, но тут уже ничего не поделать
		// а если в кэше есть, то всё отлично, пусть при следующем входе
		// на страницу пользователь пока смотрит на то, что в кеше
		//Помечаем, что эти данные  есть в кэше 
		self.excludeUrlList[request.url] = 0;
	}); 
}

В общем-то вот и всё. Осталось подключить service worker. Для этого можно использовать на странице такой скрипт:


// Проверка того, что наш браузер поддерживает Service Worker API.
if (navigator.serviceWorker) {
    // Весь код регистрации у нас асинхронный.
    navigator.serviceWorker.register('/sw01.js')
      .then(() => navigator.serviceWorker.ready.then((worker) => {
		if (worker.sync) {
			//Это выполняется в Хроме, но не выполняется в Firefox, пока не разбирался...
			console.log('Before register syncdata');
			worker.sync.register('syncdata');
		} 
		//Эта переменная понадобится нам для установки связи из браузерного скрипта с воркером
		window.cacheWorker  = worker.active;
      }))
      .catch((err) => console.log(err));
} else {
	console.log('...');
}

Скачать полный код можно отсюда. Однако, хочется упомянуть ещё о паре граблей, с которыми столкнулся.

Грабли, или как делать не надо

Грабли #1 - хочется открыть кэш один раз и работать с ним

Как вы могли заметить, в коде моего service worker кэш открывается дважды. Первый раз в getResponseFromCacheOrNetwork, а второй раз - после его вызова.

Естественно, хотелось делать эту операцию лишь один раз, написав код onFetch вот так:

Внимание, следующие фрагменты кода не работают!

/**
 * @description Перехватываем запрос
*/
function onFetch(event) {
	//Если его не нашли в кэше, значит надо отправить запрос на сервер, то есть кормить собак и ничего не трогать
	if (self.excludeUrlList[event.request.url]) {
		if (self.verbose) console.log('Skip search in cache ' + event.request.url);
		return;
	}
	//Обратимся за ответом на запрос в кэш, а если него там нет, то на сервер
	caches.open(CACHE).then((cache) => {
		event.respondWith(getResponseFromCacheOrNetwork(cache, event.request) );
	});
	
	
	//Код обновления через секунду перенесён в getResponseFromCacheOrNetwork
}

Код getResponseFromCacheOrNetwork соответственно дополним аругментом и запуском фонового обновления:


/**
 * @description Обратимся за ответом на запрос в кэш, а если него там нет, то на сервер
 * @param {Cache} cache - уже "открытый" кэш
 * @param {Request} request
 */
function getResponseFromCacheOrNetwork(cache, request) {
	//Чтобы не DDOS-ить сервер одинаковыми запросами с малым промежутком,
	// сделаем секундную паузу перед тем как обновить данные в кэше
	//Клонируем запрос, потому что его на момент
	// вызова лямбды может и не существовать
	let req = request.clone();
	setTimeout(() => {
		//Кэш уже открыт, вызовем нашу функцию update
		if (self.verbose)  console.log('Schedule update  ' + req.url);
		update(cache, req);
	}, 1000);

	//Кэш открыт,проверим, есть ли там результат, если нет возьмём в сети
	return onOpenCacheForSearchRequest(cache, request);
}

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

При фоновом обновлении кэш надо открывать отдельно. Не надо использовать экземпляр кэша, открытого для поиска в нем ответа на запрос для фонового обновления ресурса. Коварно тем, что например в Firefox 67.0 никаких ошибок не выдаёт, но результаты на странице выводятся не из кэша.

Грабли #2 - хочется использовать addAll в одном service worker, подключенному к разным страницам сайта

Метод Cache.addAll(Array aUrlList) позволяет закэшировать сразу много ресурсов. Однако у него есть особенность, если хотя бы один из переданных в массиве url вернет в ответ код не 200, вообще ничего не закэшируется.

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

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