С помощью Service Workers мы предоставили разработчикам способ решения проблем с сетевым подключением. Вы получаете контроль над кэшированием и обработкой запросов. Это означает, что вы можете создавать собственные шаблоны. Рассмотрим несколько возможных шаблонов по отдельности, но на практике вы, вероятно, будете использовать их вместе, в зависимости от URL и контекста.
Демонстрацию некоторых из этих шаблонов можно посмотреть в разделе Trained-to-thrill .
Когда хранить ресурсы
Сервис-воркеры позволяют обрабатывать запросы независимо от кэширования, поэтому я продемонстрирую их по отдельности. Сначала определим, когда следует использовать кэширование.
При установке, как зависимость

API сервис-воркера предоставляет событие install . Вы можете использовать его для подготовки компонентов, которые должны быть готовы к обработке других событий. Во время install предыдущие версии сервис-воркера продолжают работать и обслуживать страницы. Ваши действия в этот момент не должны нарушать работу текущего сервис-воркера.
Идеально подходит для : CSS, изображений, шрифтов, JS, шаблонов или чего-либо еще, что вы считаете статическим для этой версии вашего сайта.
Загрузите то, что сделает ваш сайт полностью неработоспособным, если их не загрузить, то есть то, что эквивалентное платформенно-специфическое приложение включило бы в первоначальную загрузку.
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mysite-static-v3').then(function (cache) {
return cache.addAll([
'/css/whatever-v3.css',
'/css/imgs/sprites-v6.png',
'/css/fonts/whatever-v8.woff',
'/js/all-min-v4.js',
// etc.
]);
}),
);
});
event.waitUntil принимает обещание, определяющее длительность и успешность установки. Если обещание отклоняется, установка считается неудачной, и данный сервис-воркер прекращается (если запущена более старая версия, он остаётся без изменений). caches.open() и cache.addAll() возвращают обещания. Если какой-либо из ресурсов не удаётся получить, вызов cache.addAll() отклоняется.
На training-to-thrill я использую это для кэширования статических ресурсов .
При установке, а не как зависимость

Это похоже на установку в качестве зависимости, но не приведет к задержке завершения установки и не приведет к сбою установки в случае сбоя кэширования.
Идеально подходит для : больших ресурсов, которые не нужны сразу, например, ресурсов для последующих уровней игры.
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mygame-core-v1').then(function (cache) {
cache
.addAll
// levels 11-20
();
return cache
.addAll
// core assets and levels 1-10
();
}),
);
});
В этом примере обещание cache.addAll для уровней 11–20 не передаётся обратно в event.waitUntil , поэтому даже в случае сбоя игра всё равно будет доступна офлайн. Конечно, вам придётся учитывать возможное отсутствие этих уровней и повторно попытаться кэшировать их, если они отсутствуют.
Сервис-воркер может быть остановлен во время загрузки уровней 11–20, поскольку он завершил обработку событий, а значит, они не будут кэшированы. API периодической фоновой синхронизации Web Periodic может справиться с такими ситуациями и большими загрузками, например, фильмов.
При активации

Идеально подходит для : очистки и миграции.
После установки нового сервис-воркера, если предыдущая версия не используется, активируется новая версия, и вы получаете событие activate . Поскольку предыдущая версия уже не используется, сейчас самое время выполнить миграцию схем в IndexedDB , а также удалить неиспользуемые кэши.
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames
.filter(function (cacheName) {
// Return true if you want to remove this cache,
// but remember that caches are shared across
// the whole origin
})
.map(function (cacheName) {
return caches.delete(cacheName);
}),
);
}),
);
});
Во время активации такие события, как fetch помещаются в очередь, поэтому длительная активация может блокировать загрузку страниц. Старайтесь сделать активацию максимально компактной и используйте её только для тех задач, которые вы не могли выполнить, пока была активна предыдущая версия.
На тренированных на острые ощущения я использую это для удаления старых тайников .
О взаимодействии с пользователем

Идеально подходит для случаев , когда невозможно перевести весь сайт в автономный режим, и вы решили предоставить пользователю возможность выбирать контент, который он хочет видеть в офлайн-режиме. Например, видео на YouTube, статья в Википедии или определённая галерея на Flickr.
Предоставьте пользователю кнопку «Читать позже» или «Сохранить для офлайн». При нажатии на неё он получит нужную информацию из сети и сохранит её в кэше.
document.querySelector('.cache-article').addEventListener('click', function (event) {
event.preventDefault();
var id = this.dataset.articleId;
caches.open('mysite-article-' + id).then(function (cache) {
fetch('/get-article-urls?id=' + id)
.then(function (response) {
// /get-article-urls returns a JSON-encoded array of
// resource URLs that a given article depends on
return response.json();
})
.then(function (urls) {
cache.addAll(urls);
});
});
});
API кэша доступен со страниц и сервис-воркеров, что означает, что вы можете добавлять данные в кэш непосредственно со страницы.
О сетевом ответе

Идеально подходит для частого обновления ресурсов, таких как почтовый ящик пользователя или содержание статей. Также полезно для необязательного контента, например аватарок, но требует осторожности.
Если запрос не соответствует ничему в кэше, получить его из сети, отправить на страницу и одновременно добавить в кэш.
Если вы делаете это для ряда URL-адресов, например, аватаров, будьте осторожны, чтобы не перегрузить хранилище вашего источника. Если пользователю нужно освободить место на диске, вы не хотите быть первым кандидатом. Убедитесь, что вы удалили из кэша ненужные элементы.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
return (
response ||
fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
})
);
});
}),
);
});
Для эффективного использования памяти тело ответа/запроса можно прочитать только один раз. В примере кода используется .clone() для создания дополнительных копий, которые можно читать отдельно.
На training-to-thrill я использую это для кэширования изображений Flickr .
Устаревание при повторной проверке

Идеально подходит для часто обновляемых ресурсов, где наличие последней версии не является обязательным. Аватары также могут попасть в эту категорию.
Если доступна кэшированная версия, используйте ее, но в следующий раз загрузите обновление.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
var fetchPromise = fetch(event.request).then(function (networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
}),
);
});
Это очень похоже на HTTP-функцию stale-while-revalidate .
О push-сообщении

Push API — ещё одна функция, реализованная на основе сервис-воркера. Она позволяет активировать сервис-воркер в ответ на сообщение от службы обмена сообщениями ОС. Это происходит, даже если у пользователя нет открытой вкладки вашего сайта. Активируется только сервис-воркер. Вы запрашиваете разрешение на это со страницы, и пользователю предоставляется соответствующее уведомление.
Идеально подходит для : контента, связанного с уведомлением, например, сообщения в чате, экстренные новости или электронные письма. Также для редко меняющегося контента, который требует немедленной синхронизации, например, обновления списка дел или изменения в календаре.
Обычно конечным результатом является уведомление, которое при нажатии открывает и фокусирует соответствующую страницу, и для которого крайне важно предварительно обновить кэш. Пользователь находится в сети на момент получения push-уведомления, но может быть недоступен на момент взаимодействия с уведомлением, поэтому крайне важно сделать этот контент доступным офлайн.
Этот код обновляет кэши перед показом уведомления:
self.addEventListener('push', function (event) {
if (event.data.text() == 'new-email') {
event.waitUntil(
caches
.open('mysite-dynamic')
.then(function (cache) {
return fetch('/inbox.json').then(function (response) {
cache.put('/inbox.json', response.clone());
return response.json();
});
})
.then(function (emails) {
registration.showNotification('New email', {
body: 'From ' + emails[0].from.name,
tag: 'new-email',
});
}),
);
}
});
self.addEventListener('notificationclick', function (event) {
if (event.notification.tag == 'new-email') {
// Assume that all of the resources needed to render
// /inbox/ have previously been cached, e.g. as part
// of the install handler.
new WindowClient('/inbox/');
}
});
В фоновом режиме синхронизации

Фоновая синхронизация — ещё одна функция, реализованная на основе сервис-воркера. Она позволяет запрашивать фоновую синхронизацию данных как однократно, так и через определённый интервал (что весьма эвристично). Это происходит, даже если у пользователя нет открытой вкладки на вашем сайте. Активируется только сервис-воркер. Вы запрашиваете разрешение на это со страницы, и пользователь получает соответствующее уведомление.
Идеально подходит для : несрочных обновлений, особенно тех, которые происходят настолько регулярно, что push-уведомление о каждом обновлении будет слишком частым для пользователей, например, социальные сети или новостные статьи.
self.addEventListener('sync', function (event) {
if (event.id == 'update-leaderboard') {
event.waitUntil(
caches.open('mygame-dynamic').then(function (cache) {
return cache.add('/leaderboard.json');
}),
);
}
});
Сохранение кэша
Вашему источнику предоставляется определённый объём свободного пространства для использования по своему усмотрению. Это свободное пространство распределяется между всеми хранилищами источника: (локальным) хранилищем , IndexedDB , File System Access и, конечно же, кэшами .
Объём, который вы получите, не указан. Он зависит от устройства и условий хранения. Узнать объём можно с помощью:
if (navigator.storage && navigator.storage.estimate) {
const quota = await navigator.storage.estimate();
// quota.usage -> Number of bytes used.
// quota.quota -> Maximum number of bytes available.
const percentageUsed = (quota.usage / quota.quota) * 100;
console.log(`You've used ${percentageUsed}% of the available storage.`);
const remaining = quota.quota - quota.usage;
console.log(`You can write up to ${remaining} more bytes.`);
}
Однако, как и любое хранилище браузера, он может стереть ваши данные, если на устройстве окажется недостаточно места. К сожалению, браузер не видит разницы между фильмами, которые вы хотите сохранить любой ценой, и игрой, которая вам не особо интересна.
Чтобы обойти эту проблему, используйте интерфейс StorageManager :
// From a page:
navigator.storage.persist()
.then(function(persisted) {
if (persisted) {
// Hurrah, your data is here to stay!
} else {
// So sad, your data may get chucked. Sorry.
});
Конечно, пользователь должен дать разрешение. Для этого используйте API «Разрешения».
Важно привлечь пользователя к участию в этом процессе, поскольку теперь мы можем ожидать, что он будет контролировать удаление данных. Если на устройстве не хватает места, и удаление ненужных данных не решает проблему, пользователь сам решает, какие данные оставить, а какие удалить.
Чтобы это работало, операционные системы должны рассматривать «постоянные» источники как эквивалент платформенно-зависимых приложений при анализе использования хранилища, а не сообщать о браузере как об отдельном элементе.
Предложения по подаче
Неважно, сколько кэширования вы используете, сервис-воркер использует кэш только тогда, когда вы ему это указали. Вот несколько шаблонов обработки запросов:
Только кэш

Идеально подходит для : всего, что вы считаете статическим для определённой «версии» вашего сайта. Эти данные должны быть кэшированы в событии установки, чтобы вы могли быть уверены в их наличии.
self.addEventListener('fetch', function (event) {
// If a match isn't found in the cache, the response
// will look like a connection error
event.respondWith(caches.match(event.request));
});
…хотя вам нечасто приходится обрабатывать этот случай специально, кэш, возврат к сети покрывает эту проблему.
Только сеть

Идеально подходит для : вещей, не имеющих офлайн-аналога, таких как аналитические пинги, не-GET-запросы.
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// or don't call event.respondWith, which
// will result in default browser behavior
});
…хотя вам нечасто приходится обрабатывать этот случай специально, кэш, возврат к сети покрывает эту проблему.
Кэш, откат к сети

Идеально подходит для : разработки, ориентированной на офлайн-режим. В таких случаях именно так вы будете обрабатывать большинство запросов. Другие шаблоны — это исключения, основанные на входящем запросе.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
Это дает вам поведение «только кэширование» для объектов в кэше и поведение «только сеть» для всего, что не кэшируется (включая все не-GET-запросы, поскольку они не могут быть кэшированы).
Гонка за кэш и сеть

Идеально подходит для : небольших активов, где вам нужна производительность на устройствах с медленным доступом к диску.
При некоторых сочетаниях старых жёстких дисков, антивирусных сканеров и более быстрого интернет-соединения доступ к сетевым ресурсам может быть быстрее, чем доступ к диску. Однако обращение к сети, когда у пользователя есть контент на устройстве, может быть пустой тратой данных, поэтому имейте это в виду.
// Promise.race rejects when a promise rejects before fulfilling.
// To make a race function:
function promiseAny(promises) {
return new Promise((resolve, reject) => {
// make sure promises are all promises
promises = promises.map((p) => Promise.resolve(p));
// resolve this promise as soon as one resolves
promises.forEach((p) => p.then(resolve));
// reject if all promises reject
promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
});
}
self.addEventListener('fetch', function (event) {
event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});
Сеть возвращается к кэшу

Идеально подходит для : быстрого решения проблем с часто обновляемыми ресурсами, выходящими за рамки «версии» сайта. Например, статьи, аватары, ленты социальных сетей и таблицы лидеров игр.
Это означает, что вы предоставляете онлайн-пользователям самый актуальный контент, а офлайн-пользователи получают более старую версию из кэша. Если сетевой запрос выполнен успешно, вам, скорее всего, потребуется обновить запись в кэше .
Однако у этого метода есть недостатки. Если у пользователя нестабильное или медленное соединение, ему придётся ждать сбоя сети, прежде чем он получит на своё устройство контент приемлемого качества. Это может занять очень много времени и доставить неудобства пользователю. Более эффективное решение можно найти в следующем шаблоне: «Кэш, затем сеть» .
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
Кэш, затем сеть

Идеально подходит для : часто обновляемого контента, например, статей, лент новостей в социальных сетях и игр, таблиц лидеров.
Для этого страница должна выполнить два запроса: один к кешу, а другой — к сети. Идея заключается в том, чтобы сначала отобразить кэшированные данные, а затем обновить страницу по мере поступления сетевых данных.
Иногда можно просто заменить текущие данные при появлении новых (например, в таблице лидеров игры), но это может помешать работе с большими фрагментами контента. Главное, не «исчезайте» то, что пользователь читает или с чем взаимодействует.
Twitter добавляет новый контент поверх старого и меняет положение прокрутки, чтобы не отвлекать пользователя. Это возможно благодаря тому, что Twitter сохраняет преимущественно линейный порядок размещения контента. Я скопировал этот шаблон для training-to-thrill , чтобы контент появлялся на экране как можно быстрее, при этом отображая актуальный контент сразу по мере его появления.
Код на странице :
var networkDataReceived = false;
startSpinner();
// fetch fresh data
var networkUpdate = fetch('/data.json')
.then(function (response) {
return response.json();
})
.then(function (data) {
networkDataReceived = true;
updatePage(data);
});
// fetch cached data
caches
.match('/data.json')
.then(function (response) {
if (!response) throw Error('No data');
return response.json();
})
.then(function (data) {
// don't overwrite newer network data
if (!networkDataReceived) {
updatePage(data);
}
})
.catch(function () {
// we didn't get cached data, the network is our last hope:
return networkUpdate;
})
.catch(showErrorMessage)
.then(stopSpinner);
Код в сервис-воркере :
Вам следует всегда выходить в сеть и обновлять кэш по мере доступа.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
});
}),
);
});
В training-to-thrill я обошел это, используя XHR вместо fetch и злоупотребляя заголовком Accept, чтобы сообщить сервисному работнику, откуда получить результат ( код страницы , код сервисного работника ).
Общий резервный вариант

Если вам не удалось обслужить что-либо из кэша или сети, предоставьте общий запасной вариант.
Идеально подходит для : вторичных изображений, таких как аватары, неудачные запросы POST и страница «Недоступно в автономном режиме».
self.addEventListener('fetch', function (event) {
event.respondWith(
// Try the cache
caches
.match(event.request)
.then(function (response) {
// Fall back to network
return response || fetch(event.request);
})
.catch(function () {
// If both fail, show a generic fallback:
return caches.match('/offline.html');
// However, in reality you'd have many different
// fallbacks, depending on URL and headers.
// Eg, a fallback silhouette image for avatars.
}),
);
});
Элемент, к которому вы откатываетесь, скорее всего, будет зависимостью от установки .
Если ваша страница отправляет электронное письмо, ваш сервис-воркер может вернуться к сохранению письма в папке исходящих сообщений IndexedDB и сообщить странице, что отправка не удалась, но данные были успешно сохранены.
Шаблонизация на стороне сервисного работника

Идеально подходит для : страниц, для которых невозможно кэшировать ответ сервера.
Страницы быстрее визуализируются на сервере , но это может означать включение данных о состоянии, которые могут быть бессмысленны в кэше, например, о состоянии входа в систему. Если ваша страница управляется сервис-воркером, вы можете запросить JSON-данные вместе с шаблоном и визуализировать его.
importScripts('templating-engine.js');
self.addEventListener('fetch', function (event) {
var requestURL = new URL(event.request.url);
event.respondWith(
Promise.all([
caches.match('/article-template.html').then(function (response) {
return response.text();
}),
caches.match(requestURL.path + '.json').then(function (response) {
return response.json();
}),
]).then(function (responses) {
var template = responses[0];
var data = responses[1];
return new Response(renderTemplate(template, data), {
headers: {
'Content-Type': 'text/html',
},
});
}),
);
});
Собери это вместе
Вы не ограничены одним из этих методов. На самом деле, вы, вероятно, будете использовать многие из них в зависимости от URL-адреса запроса. Например, training-to-thrill использует:
- Кэширование при установке для статического пользовательского интерфейса и поведения
- Кэширование при сетевом ответе для изображений и данных Flickr
- Извлечение из кэша с возвратом к сети для большинства запросов
- Извлечь из кэша, а затем из сети для результатов поиска Flickr
Просто посмотрите на запрос и решите, что делать:
self.addEventListener('fetch', function (event) {
// Parse the URL:
var requestURL = new URL(event.request.url);
// Handle requests to a particular host specifically
if (requestURL.hostname == 'api.example.com') {
event.respondWith(/* some combination of patterns */);
return;
}
// Routing for local URLs
if (requestURL.origin == location.origin) {
// Handle article URLs
if (/^\/article\//.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/\.webp$/.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (request.method == 'POST') {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/cheese/.test(requestURL.pathname)) {
event.respondWith(
new Response('Flagrant cheese error', {
status: 512,
}),
);
return;
}
}
// A sensible default pattern
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
Дальнейшее чтение
- Сервисные работники и API хранилища кэша
- Обещания JavaScript — введение : руководство по обещаниям
Кредиты
Для прекрасных иконок:
- Код от buzzyrobot
- Календарь Скотта Льюиса
- Сеть Бена Риццо
- SD Томаса Ле Баса
- ЦП от iconsmind.com
- Trash by trasnik
- Уведомление от @daosme
- Макет от Mister Pixel
- Облако от PJ Onori
И спасибо Джеффу Поснику за то, что он обнаружил множество грубых ошибок, прежде чем я нажал «опубликовать».