Блог Андрея

 
 

Кэширование с помощью service worker при первой загрузке страницы.

Продолжение статьи. Напомню, что я реализовал стратегию «Если ресурс есть в кэше, берём его из кэша, если нет, берём с сервера. Через секунду обновляем кэш в фоне».

Реализованый в прошлой статье service worker имеет один существенный недостаток и одну существенную недоработку.

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

В этой статье мы избавимся от перечисленных недостатков, научимся устанавливать двустороннюю связь между нашей страницей и service worker, а главное напишем очень удачный скрипт, который можно будет использовать на разных сайтах или progressive web apps без каких-либо модификаций.

Кэширование ресурсов страницы сразу после установки и активации service worker

Во многих статьях описан подход, когда после установки service worker используется метод Cache.addAll(aUrlList), например:


const CACHE = 'cache-update-and-refresh-v1';

// При установке воркера мы должны закешировать часть данных (статику).
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches
            .open(CACHE)
            .then((cache) => cache.addAll(['/img/background']))
    );
});

Мне этот подход сразу не понравился. Во-первых, у конечных пользователей файл с кодом service worker будет обновляться в лучшем случае раз в 24 часа.

Во-вторых я хочу использовать один service worker для всех страниц и даже разделов сайта, причем в некоторых разделах список css js и прочего может быть полностью другим - я хочу пробовать разные технологии.

Эти мысли пришли мне в голову ещё до того, как я узнал об очень неприятной особенности метода addAll. Оказалось, что если в списке url передать ему всего один url, который вернёт код отличный от 200, провалится кэширование всего списка.

Поэтому, я вообще отказался от использования метода addAll и от использования кэширования чего-либо в onInstall. Вместо этого я решил, что было бы неплохо, чтобы страница собирала из своего содержимого все url, ссылающиеся на тот сайт, на котором она находится и передавала этот список в service worker.

Причём, делать это надо только при первом запуске, так как иначе все ресурсы страницы будут обновляться дважды: через секунду после вызова onFetch и в момент получения сообщения со списком url от клиента.

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

В итоге я написал скрипт cacheclient.js, который осуществляет сбор всех url со страницы, которая открыта в браузере и отправляет их service worker. Это сделало мой скрипт универсальным для многих сайтов.

Рассмотрим установку слушателя сообщений от service worker


//Конструктор, извините за es5
function CacheClient(){
	this.init();
}
/**
 * @description Необходимо вызвать по событию DOMContentLoaded
*/
CacheClient.prototype.init = function() {
	var o = this;
	o.verbose = true;
	//Заполняется url которые есть на странице и указывают 
	// на данный сайт. Заполнение происходит в getAllResources
	o._aUrlMap = {};
	
	navigator.serviceWorker.addEventListener('message', info => {
	  o.onMessage(info);
	});
}

Извините за es5 - но так зато проще отличать, о каком скрипте идёт речь - если функции определены явно в стиле es6 значит это код из service worker, если нет, значит из cacheclient.js.

В onMessage принимаем уведомление от service worker:


/**
 * @description Обработка сообщения от ServiceWorker
 * @return array
*/ 
CacheClient.prototype.onMessage = function(info) {
	var o = this;
	if (o.verbose) console.log('CacheClient OnMessage:', info);
	
	if (info.data.type == 'isFirstRun') {
		if (o.verbose) console.log('CacheClient OnMessage: got event FirstRun! ');
		if (window.cacheWorker) {
			//getAllResources вернёт список url
			window.cacheWorker.postMessage(o.getAllResources());
		}
	}
	if (info.data.type == 'hasUpdate') {
		//это мы рассмотрим ниже
	}
}

Сам сбор url на странице малоинтересен в контексте данной статьи, поэтому я не буду на нем останавливаться. Желающие могут посмотреть код getAllResources() на гитхаб.

Однако замечу, что все url в getAllResources() полные, то есть начинаются с https. Мне показалось, что в какой-то момент Хром перестал обрабатывать url начинающиеся с '/'.

Куда важнее объяснить, откуда взялся window.cacheWorker. Если вы внимательно смотрели предыдущую статью, вы это знаете, но если это прошло мимо вас, приведу ещё раз код регистрации service worker:


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

Мы сделали всё, чтобы получить сообщение от service worker, нам осталось написать код его отправки.

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


/**
 * @description Удобная отправка сообщений клиентам (Кто такие клиенты см. onActivate)
 * @param {String} sType
 * @param {String} sUpdUrl используется для сообщения hasUpdate 
 *	чтобы клиент мог проверить, есть ли ресурс с таким url на странице и если
 *  есть, обновить
*/
function sendMessageAllClients(sType, sUpdUrl) { 
	//Найти всех клиентов
	self.clients.matchAll()
		//Когда нашли
		.then((clients) => {
			//Перебрать всех и отправить им сообщение в виде объекта
			clients.forEach((client) => {
				if (self.verbose) console.log('founded client: ', client);
				let message = {
					type: sType,
					resources: self.cachingResources,
					updUrl: sUpdUrl,
					clientUrl: client.url
				};
				// Уведомляем клиент об обновлении данных.
				client.postMessage(message);
			});
		});
}

А в обработку события активации добавим отправку сообщения isFirstRun:


/**
 * @description Обработка события активации
 */
function onActivate(){
	//Сообщим всем клиентам (клиенты - это например открытые вкладки с разными страницами вашего сайта в браузере)
	// сообщим, что мы тут и работаем.
	if (self.verbose) console.log('Activation event!');
	self.clients.claim();
	
	//Если это первый запуск, надо сообщить страницам,
	// чтобы прислали списки url которые надо кэшировать
	setTimeout(() => {
		if (self.verbose) console.log('Worker: send First Run!');
		sendMessageAllClients('isFirstRun');
	}, 1000);
}

Отлично, теперь мы отправляем сообщения из service worker и отвечаем из скрипта, который подключен к странице, осталось принять сообщенине со списком ресурсов в service worker:


/**
 * @description Приём сообщений от клиента (Кто такие клиенты см. onActivate)
 * @param {Object} {data, origin} info
*/
function onPostMessage(info) {
	//Кэшируем все переданные ресурсы
	caches.open(CACHE).then((cache) => {
		for (let i = 0; i < info.data.length; i++) {
			if (self.verbose) console.log('First run caching resource ' + info.data[i]);
			update(cache, info.data[i]);
		}
	});
}

Вместо adAll я использую уже написаную нами в предыдущей статье фуннкцию update(). Как оказалось, она отлично работает и в том случае, если ей передать url типа String вместо объекта Request.

Дорабатываем скрипт для показа уведомлений вида «Есть новый контент».

Хочется, чтобы service worker после обновления кэша как-то прjверял, не обновился ли загруженный в фоне ресурс и уведомлял об этом пользователя, открывшего страницу.

Выяснить это можно, сравнив заголовок ответа сервера, полученного только что с заголовком ответа сервера, полученного ранее и сохранённого в кэше.

Но как оказалось, не во всех ответах сервера может присутствовать заголовок last-modified, содержащий время последней модификации ресурса, да и content-length мне вообще ни в одном из ответов сервера, на котором находится этот сайт в 2019 году обнаружить не удалось.

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

Поэтому, я решил определять длину контента непосредственно при его получении, но только в том случае, если нет заголовка last-modified.

У нас уже есть написанная нами в предыдущей статье функция onFoundResInCache(result), добавим в неё вызов функции saveResultHeadersData(result):


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

И напишем функцию saveResultHeadersData:

	
/**
 * @description Сохраним время последней модификации ресурса,
 *  а если это невозможно, его размер.
 * Данная функция вызывается из onFoundResInCache
 * @param {Response} result - найденный в кэше ответ на запрос
 */
function saveResultHeadersData(result) {
	if (result.headers && result.url) {
		let sContentType = result.headers.has('content-type') ? 
			result.headers.get('content-type') :
			'';
		//Выводим сообщения только в том случае, если обновились текст или картинки
		if (sContentType.indexOf('text/html') != -1 
			|| sContentType.indexOf('image/') != -1
			//|| sContentType.indexOf('application/json') != -1
			) {
				//если сервер передал время последнего изменения ресурса, нам повезло, можно не мудрить
				if (result.headers.has('last-modified')) {
					if (self.verbose) console.log('Will save lastmtime "' + result.headers.get('last-modified') + '"');
					//Просто запомним, что у нас в кэше лежит ресурс, изменённый тогда-то
					self.lastModUrlList[result.url] = result.headers.get('last-modified');
				} else {
					//если сервер не передал время последнего изменения ресурса, будем мудрить
					if (self.verbose) console.log('has no lastmtime for url "' + result.url + '"');
					//если нет такого заголовка сохраняем длину контента
					//Так здесь text() возвращает Promise, пришлось клонировать, иначе была ошибка искажения содержимого
					result.clone().text().then((str) => {
						if (self.verbose) console.log('Will save length "' + str.length + '" for "' + result.url + '"');
						//Просто запомним длину контента ресурса в кэше
						self.contentLengthUrlList[result.url] = str.length;
					});
				}
		}
	}
}

Разберу эту функцию подробно.

Мне показалось, что изменения javascript и css на сайте могут быть довольно частыми, при этом пользователь может даже не понять, что именно изменилось и увидит лишь непонятное назойливое сообщение, что вот мол есть новая версия страницы. Поэтому я решил, что буду контролировать изменения только html сайта и изображений. Делаю я это, анализируя заголовок content-type. В случае с html текстом и например png изображениями он будет содержать в значении 'text/html' и 'image/png' соответственно.

Поначалу я хотел включить туда и результаты ajaх запросов, но потом мне это показалось излишним, поэтому поиск подстроки 'application/json' закомментирован. В самом деле, такие запросы если и выводят новый контент, они обычно делают это без перезагрузки страницы.

Далее, я проверяю существование загловка last-modified и если он есть, сохраняю в объекте self.lastModUrlList время модификации хранимого в кэше ресурса.

Напомню, что в реализованном service worker обновление ресурса в кэше всегда происходит после того, как ресурс считан из кэша. Таким образом, если ресурс в кэше был, на момент обновления в объекте self.lastModUrlList будет хранится необходимая для проверки изменения ресурса информация.

Но, так как заголовок last-modified может не передаваться для content-type="text/html", я перестраховываюсь, получая для таких ресурсов их размер.

Логика в этом случае по сути аналогична логике с last-modified, но мне приходится использовать другой объект, self.contentLengthUrlList и вдобавок дожидаться "разрешения" Promise, который возвращает метод Request.text().

Поначалу я не клонировал result, но получил ошибку искажения содержимого (это когда вместо страницы сайта ваш Firefox показывает сообщение "Ошибка искажения содержимого").

И конечно, надо не забыть добавить инициализацию новых объектов в начале кода нашего service worker:


/**
 * @description Здесь будем хранить last-modified каждого найденного в кэше url
 * Чтобы иметь возможность вывести уведомление типа "контент изменился"
*/
self.lastModUrlList = {};

/**
 * @description Здесь будем хранить длину контента страниц, не имеющих last-modified для каждого найденного в кэше url
 * Чтобы иметь возможность вывести уведомление типа "контент изменился"
*/
self.contentLengthUrlList = {};

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

Напомню, что обновление кэша запускается у нас в обработчике события fetch onFetch:


/**
 * @description Перехватываем запрос
*/
function onFetch(event) {
	//Если его не нашли в кэше, значит надо отправить запрос 
	// на сервер, то есть кормить собак и ничего не трогать
	if (self.excludeUrlList[event.request.url]) {
		if (self.verbose) console.log('Skip search in cache ' + event.request.url);
		return;
	}
	//Обратимся за ответом на запрос в кэш, а если него там нет, то на сервер
	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, true);
		});
	}, 1000);
}

Мне понадобилось внести минимальное изменение в этот код. Я добавил передачу третьего агрумента true в update, таким образом она будет знать, что необходимо сравнить данные обновлённых ресурсов с данными ресурсов их кэша. Естественно, пришлось подредактировать и update:


/**
 * @description Запрос данных с сервера. Этот метод вызывать в onOpenCache... , когда доступен объект открытого кэша cache
 * @param {Cache} cache - кеш, в котором ищем, на момент вызова должен уже быть открыт
 * @param {Request} request
 * @param {Boolean} isUpdateCacheAction true когда обновление происходит не потому, 
 *		что в кэше не найдено, а потому, что это обновление данных в кэше,
 *       хотя они там есть
 * @return Promise -> HttpResponse данные с сервера
*/
function update(cache, request, isUpdateCacheAction) {
	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() );
			//Уведомим страницу, что на ней есть новые данные (если они есть)
			if (isUpdateCacheAction) {
				if (self.verbose) console.log('Will try send message about upd');
				checkResponseForUpdate(response);
			}
			//Помечаем, что эти данные уже есть в кэше
			self.excludeUrlList[request.url] = 0;
		}
		//вернем ответ сервера
		return response;
	})
	//Сервер не ответил, например связь оборавалсь
	.catch((err) => {
		//Если с сервера ничего полезного не пришло, а в кэше у нас
		// тоже ничео нет, всё печально, но тут уже ничего не поделать
		// а если в кэше есть, то всё отлично, пусть при следующем
		// входе на страницу пользователь пока смотрит на то,
		// что уже есть в кеше
		//Помечаем, что эти данные  есть в кэше 
		self.excludeUrlList[request.url] = 0;
	}); 
}

В обработку получения успешного ответа от сервера добавился блок:


//Уведомим страницу, что на ней есть новые данные (если они есть)
if (isUpdateCacheAction) {
	if (self.verbose) console.log('Will try send message about upd');
	checkResponseForUpdate(response);
}

Сама проверка и отправка уведомления на страницу происходит в checkResponseForUpdate(response):


/**
 * @description Уведомим страницу, что на ней есть новые данные (если они есть)
 * @param {Response} result
 */
function checkResponseForUpdate(response) {
	if (response.status == 200 && response.url) {
		//Ищем по last-modified
		if (self.lastModUrlList[response.url] && response.headers && response.headers.has('last-modified')) {
			if (self.lastModUrlList[response.url] != response.headers.get('last-modified')) {
				//Отправим всем открытым страницам уведомление, что ресурсс с response.url изменился
				sendMessageAllClients('hasUpdate', response.url);
			}
		}
		
		//Ищем по изменению длины контента
		if (self.contentLengthUrlList[response.url]) {
			response.clone().text().then((str) => {
				if (self.contentLengthUrlList[response.url] != str.length) {
					//Отправим всем открытым страницам уведомление, что ресурсс с response.url изменился
					sendMessageAllClients('hasUpdate', response.url);
				}
			});
		}
	}
}

Тут я думаю всё понятно. Заголовки ответа сервера анализируются аналогично заголовкам ответов ранее сохранённых в кэше. Единственное, на что стоит обратить внимание, анализ начинается только в том случае, если соответствующий url есть в наших объектах lastModUrlList или contentLengthUrlList.

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

Снова посмотрим на наш метод onMessage из cacheclient.js:


/**
 * @description Обработка сообщения от ServiceWorker
 * @return array
*/ 
CacheClient.prototype.onMessage = function(info) {
	var o = this;
	if (o.verbose) console.log('CacheClient OnMessage:', info);
	
	if (info.data.type == 'isFirstRun') {
		//Это мы уже разобрали
	}
	if (info.data.type == 'hasUpdate') {
		var sUpdUrl = info.data.updUrl,
			oHashResources = o.getAllResourcesHash();
		if (!o.updateMessageIsShowed && oHashResources[info.data.updUrl]) {
			//Чтобы не показывать сообщение 10 раз если 
			// обновлены все 10 картинок на странице
			o.updateMessageIsShowed = true;
			o.showUpdateMessage();
		}
	}
}

Если пришло сообщение с типом hasUpdate, проверяем, имеет ли изменившийся url какое-то отношение к нам (то есть содержится ли на странице ресурс с таким url) и если да, показываем сообщение.

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

showUpdateMessage() реализован в коде cacheclient.js аскетично просто - в виде стандартного alert-а. Если вы захотите его изменить, вам следует наследоваться от CacheClient и перегрузить в нём этот метод.

Использовать то, что у нас получилось можно по ссылкам:

Код service worker на github он должен открыватсья по адресу https://your.site/landcachersw.js

Код скрипта регистрации service worker на github не включайте его в один большой яваскрипт, который генерируется вашим webpack или gulp - он лучше всего работает, когда он подключен в начале страницы!

Код cacheclient.js на github его можно и нужно включить в один большой яваскрипт, который генерируется вашим webpack или gulp

Пример перегрузки некоторых функций CaheClient для кастомизации поведения кэширования

Ссылка на первую часть этой статьи