Service Workers: прозрачное обновление кэша

в 2:39, , рубрики: cache, html5, javascript, progressive web apps, pwa, serviceworker, Клиентская оптимизация, кэширование, Программирование, Разработка веб-сайтов, фронтенд

Service Workes как технология для создания offline приложений очень хорошо подходит для кэширования различных ресурсов. Разнообразные тактики работы в сервис воркере с локальным кэшем подробно описаны в Интернете.

Не описано одного — каким образом обновлять файлы в кэше. Единственное, что предлагает Google и MDN, это делать несколько кэшей для разных типов ресурсов, и, когда нужно, изменять в скрипте сервис воркера sw.js версию этого кэша, после чего тот весь удалится.

Удаление кэшей
var CURRENT_CACHES = {
    font: 'font-cache-v1',
    css:'css-cache-v1',
    js:'js-cache-v1'
};

self.addEventListener('activate', function(event) {
    var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) {
        return CURRENT_CACHES[key];
    });
    // Delete out of date cahes
    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(
                cacheNames.map(function(cacheName) {
                    if (expectedCacheNames.indexOf(cacheName) == -1) {
                        console.log('Deleting out of date cache:', cacheName);
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

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

Из сторонних продуктов (хотя и от Google разработчиков) самой близкой к решению задачи обновления файлов кэша сервис воркера является sw-precache библиотека. Она добавляет в sw.js хэши всех файлов, отслеживание изменений которых задано разработчиком. При изменении на сервере хоть одного из них при следующей активации сервис воркера опять обновляется весь кэш у клиента, — но теперь уже без специальных телодвижений программиста. Топор заменили колуном.

Постановка задачи

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

Возьмем распространенный гугловский пример сервис воркера, работающий по принципу: «сперва из кэша, если там нет — из сети».

Для начала понятно, что нужно иметь список отслеживаемых файлов. Так же, нужно как-то сравнивать их с версиями файлов в кэше. Это можно делать или на сервере, или на клиенте.

Вариант 1

Используем куки. Будем записывать в куки пользователя время его последнего посещения. При следующем заходе сравниваем на сервере его со временем модификации отслеживаемых файлов, и передаем список измененных с того момента файлов в html коде страницы, непосредственно перед регистрацией сервис воркера. Для этого инклюдим туда вывод вот такого php файла:

updated_resources.php

<?php
$files = [
	"/css/fonts.css", 
	"/css/custom.css", 
	"/js/m-js.js", 
	"/js/header.css"];
	
$la = $_COOKIE["vg-last-access"];
if(!isset($la)) $la = 0;

forEach($files as $file) {
	if (filemtime(__DIR__ . "/../.." . $file) > $la) {
		$updated[] = $file;
		echo "<script src='/update-resource$file'></script>n";
	}
}

setcookie("vg-last-access", time(), time() + 31536000, "/");
?>

$files — массив отслеживаемых ресурсов. Для каждого измененного файла будет сгенерирован script таг с ключевым словом /update-resource, что повлечет за собой запрос в сервис воркер.

Там мы фильтруем эти запросы по ключевому слову и загружаем ресурсы заново.

sw.js fetch

self.addEventListener('fetch', function(event) {
	var url = event.request.url;
	if (url.indexOf("/update-resource") > 0) {
		var r = new Request(url.replace("/update-resource", ""));
		return fetchAndCache(r);
	}
// Дальше берем из кэша, если нет - снова fetchAndCache()
...

});

Вот и все, ресурсы обновляются по мере изменения. Однако есть слабые места: куки могут пропадать, и тогда пользователю придется загружать все файлы заново. Так же есть вероятность, что после установки пользователю куки он по каким-то причинам не сможет загрузить все обновленные файлы. В этом случае у него будет «битое» приложение. Попробуем придумать что-то понадежней.

Вариант 2

Будем, как и ребята из Google, передавать отслеживаемые файлы в sw.js, и сверять изменения на стороне клиента. В качестве функционала меры не изобретая хэш-велосипеда возьмем E-Tag или Last-Modified заголовки респонзов — они прекрасно сохраняются в кэше воркера. Правильней взять E-Tag, но чтобы получить его на стороне сервера необходимо будет выполнить локальный запрос к веб-серверу, что немного накладно, а Last-Modified прекрасно вычисляется с помощью filemtime().

Итак, вместо sw.js регистрируем теперь sw.php со следующим кодом:

sw.php

<?php
header("Content-Type: application/javascript");
header("Cache-Control: no-store, no-cache, must-revalidate");
$files = [
	"/css/fonts.css", 
	"/css/custom.css", 
	"/js/m-js.js", 
	"/js/header.js"];
	
echo "var updated = {};n";	
forEach($files as $file) {
	echo "updated['$file'] = '" . gmdate("D, d M Y H:i:s GMT", filemtime(__DIR__ . $file)) . "';n";
}
echo "nn";
readfile('sw.js');
?>

Он генерирует в начале sw.js объявление ассоциативного массива, инициализированного парами {url, Last-Modified} наших отслеживаемых ресурсов.

var updated = {};
updated['/css/fonts.css'] = 'Mon, 07 May 2018 02:47:54 GMT';
updated['/css/custom.css'] = 'Sat, 05 May 2018 13:10:07 GMT';
updated['/js/m-js.js'] = 'Mon, 07 May 2018 11:33:56 GMT';
updated['/js/header.js'] = 'Mon, 07 May 2018 15:34:08 GMT';

Дальше при каждом запросе клиентом ресурса в случае попадания url в массив updated сверяем с тем, что у нас в кэше.

sw.js fetch

self.addEventListener('fetch', function(event) {
	console.log('Fetching:', event.request);
	
	event.respondWith(async function() {
		const cachedResponse = await caches.match(event.request);
		if (cachedResponse) {
			console.log("Cached version found: " + event.request.url);
			var l = new URL(event.request.url);
			if (updated[l.pathname] === undefined || updated[l.pathname] == cachedResponse.headers.get("Last-Modified")) {
				console.log("Returning from cache");
				return cachedResponse;
			}
			console.log("Updating to recent version");
		}				
		return await fetchAndCache(event.request);
	}());

});

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

Единственный оставшийся момент — после загрузки ресурса необходимо будет обновить updated[url.pathname] новым response.headers.get(«Last-Modified») — есть вероятность, что этот файл после последнего получения sw.php был еще раз обновлен, тогда получится несовпадение хидера времени последнего изменения, и это файл будет постоянно обновляться при запросе.

Выводы

Нужно помнить о цикле жизни sw.js/sw.php. Этот файл подчиняется правилам стандартного кэширования браузера за одним исключением — живет он на клиенте не больше 24 часов, затем будет принудительно перезагружен при очередной регистрации сервис воркера. С sw.php у нас почти гарантированно всегда будет свежая версия.

Если не хочется влазить в генерацию sw.js, можно скачивать список отслеживаемых ресурсов с Last-Modified с сервера в блоке activate — это, наверное, более правильный способ, но ценой одного лишнего запроса на сервер. А можно как в варианте 1 врезаться в код html страницы, сформировать там ajax запрос с json данными в сервис воркер, где его и обработать, проведя инициализацию массива updated — это, возможно, самый оптимальный и динамичный вариант, он позволит при желании обновлять ресурсы кэша без переустановки сервис воркера.

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

Еще один пример приложения — картинки с различным размером (srcset или программная установка). При загрузке такого ресурса можно сперва в кэше поискать изображение бОльшего разрешения, сэкономив тем самым запрос на сервер. Или использовать картинку меньшего размера на время загрузки основной.

Из общих кэширующих техник также интересна преждевременная загрузка: допустим, известно, что в следующем релизе приложения появятся дополнительные ресурсы — новый шрифт или тяжелая картинка, например. Можно ее загрузить в кэш заранее по событию load — когда у пользователя откроется полностью страница и он начнет ее читать. Это будет незаметно и эффективно.

Напоследок пример рабочего sw.js (работает в связке с вышеуказанным sw.php) с несколькими кэшами (в том числе с кэшированием сгенерированных php скриптом картинок) и реализованным прозрачным обновлением кэша по второму варианту.

sw.js

// Caches
var CURRENT_CACHES = {
    font: 'font-cache-v1',
    css:'css-cache-v1',
    js:'js-cache-v1',
    icons: 'icons-cache-v1',
    icons_ext: 'icons_ext-cache-v1',
    image: 'image-cache-v1'
};

self.addEventListener('install', (event) => {
	self.skipWaiting();
    console.log('Service Worker has been installed');
});


self.addEventListener('activate', (event) => {
    var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) {
        return CURRENT_CACHES[key];
    });
    // Delete out of date cahes
    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(
                cacheNames.map(function(cacheName) {
                    if (expectedCacheNames.indexOf(cacheName) == -1) {
                        console.log('Deleting out of date cache:', cacheName);
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
    console.log('Service Worker has been activated');	
});


self.addEventListener('fetch', function(event) {
	console.log('Fetching:', event.request.url);
	
	event.respondWith(async function() {
		const cachedResponse = await caches.match(event.request);
		if (cachedResponse) {
			// console.log("Cached version found: " + event.request.url);
			var l = new URL(event.request.url);
			if (updated[l.pathname] === undefined || updated[l.pathname] == cachedResponse.headers.get("Last-Modified")) {
				// console.log("Returning from cache");
				return cachedResponse;
			}
			console.log("Updating to recent version");
		}				
		return await fetchAndCache(event.request);
	}());

});


function fetchAndCache(url) {
	return fetch(url)
	.then(function(response) {
		// Check if we received a valid response
		if (!response.ok) {
			return response;
			// throw Error(response.statusText);
		}
		
		// console.log('  Response for %s from network is: %O', url.url, response);
		if (response.status < 400 &&
			response.type === 'basic' &&
			response.headers.has('content-type')) {
			// debugger;
	
			var cur_cache;
			if (response.headers.get('content-type').indexOf("application/javascript") >= 0) {
				cur_cache = CURRENT_CACHES.js;
			} else if (response.headers.get('content-type').indexOf("text/css") >= 0) {
				cur_cache = CURRENT_CACHES.css;
			} else if (response.headers.get('content-type').indexOf("font") >= 0) {
				cur_cache = CURRENT_CACHES.font;
			} else if (url.url.indexOf('/css/icons/') >= 0) {
				cur_cache = CURRENT_CACHES.icons;
			} else if (url.url.indexOf('/misc/image.php?') >= 0) {
				cur_cache = CURRENT_CACHES.image;
			}
			if (cur_cache) {
				console.log('  Caching the response to', url);
				return caches.open(cur_cache).then(function(cache) {
					cache.put(url, response.clone());
					updated[(new URL(url.url)).pathname] = response.headers.get("Last-Modified");
					return response;
				});
			}
		}
		return response;

	})
	.catch(function(error) {
		console.log('Request failed:', error);
		throw error;
		// You could return a custom offline 404 page here
	});
}

Автор: altrus

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js