Service Workes как технология для создания offline приложений очень хорошо подходит для кэширования различных ресурсов. Разнообразные тактики работы в сервис воркере с локальным кэшем подробно описаны в Интернете.
Не описано одного — каким образом обновлять файлы в кэше. Единственное, что предлагает Google и MDN, это делать несколько кэшей для разных типов ресурсов, и, когда нужно, изменять в скрипте сервис воркера sw.js версию этого кэша, после чего тот весь удалится.
Другими словами, если у вас, например, десять js файлов, и вы изменили один из них, всем пользователям придется перегружать все js файлы. Достаточно топорная работа.
Из сторонних продуктов (хотя и от Google разработчиков) самой близкой к решению задачи обновления файлов кэша сервис воркера является sw-precache библиотека. Она добавляет в sw.js хэши всех файлов, отслеживание изменений которых задано разработчиком. При изменении на сервере хоть одного из них при следующей активации сервис воркера опять обновляется весь кэш у клиента, — но теперь уже без специальных телодвижений программиста. Топор заменили колуном.
Нам нужно прозрачное и надежное обновление файлов кэша сервис воркера. Это означает, что разработчик выкладывает измененные файлы на сервер, и у пользователя только они автоматически обновляются при следующем заходе/запросе. Попробуем решить эту задачу.
Возьмем распространенный гугловский пример сервис воркера, работающий по принципу: «сперва из кэша, если там нет — из сети».
Для начала понятно, что нужно иметь список отслеживаемых файлов. Так же, нужно как-то сравнивать их с версиями файлов в кэше. Это можно делать или на сервере, или на клиенте.
Используем куки. Будем записывать в куки пользователя время его последнего посещения. При следующем заходе сравниваем на сервере его со временем модификации отслеживаемых файлов, и передаем список измененных с того момента файлов в html коде страницы, непосредственно перед регистрацией сервис воркера. Для этого инклюдим туда вывод вот такого php файла:
$files — массив отслеживаемых ресурсов. Для каждого измененного файла будет сгенерирован script таг с ключевым словом /update-resource, что повлечет за собой запрос в сервис воркер.
Там мы фильтруем эти запросы по ключевому слову и загружаем ресурсы заново.
Вот и все, ресурсы обновляются по мере изменения. Однако есть слабые места: куки могут пропадать, и тогда пользователю придется загружать все файлы заново. Так же есть вероятность, что после установки пользователю куки он по каким-то причинам не сможет загрузить все обновленные файлы. В этом случае у него будет «битое» приложение. Попробуем придумать что-то понадежней.
Будем, как и ребята из Google, передавать отслеживаемые файлы в sw.js, и сверять изменения на стороне клиента. В качестве функционала меры не изобретая хэш-велосипеда возьмем E-Tag или Last-Modified заголовки респонзов — они прекрасно сохраняются в кэше воркера. Правильней взять E-Tag, но чтобы получить его на стороне сервера необходимо будет выполнить локальный запрос к веб-серверу, что немного накладно, а Last-Modified прекрасно вычисляется с помощью filemtime().
Итак, вместо sw.js регистрируем теперь sw.php со следующим кодом:
Он генерирует в начале sw.js объявление ассоциативного массива, инициализированного парами {url, Last-Modified} наших отслеживаемых ресурсов.
Дальше при каждом запросе клиентом ресурса в случае попадания url в массив updated сверяем с тем, что у нас в кэше.
Пятнадцать строчек кода, и можно спокойно выкладывать на сервер файлы, и они будут сам обновляться в кэше клиентов.
Единственный оставшийся момент — после загрузки ресурса необходимо будет обновить 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 скриптом картинок) и реализованным прозрачным обновлением кэша по второму варианту.
Не описано одного — каким образом обновлять файлы в кэше. Единственное, что предлагает 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 \G\M\T", filemtime(__DIR__ . $file)) . "';\n";
}
echo "\n\n";
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
});
}