Офлайн-приложения: сборник рекомендаций
Когда мы оставили попытки решить проблему работы приложений в офлайн-режиме и решили дать инструменты для этого самим разработчикам, то на свет появились сервис-воркеры. Они позволяют управлять кешированием и обработкой запросов, тем самым открывая возможности для реализации собственных паттернов. Мы рассмотрим несколько возможных паттернов по отдельности, но на практике вы, вероятно, будете комбинировать их друг с другом в зависимости от URL-адреса и контекста.
Увидеть эти паттерны в действии можно в демонстрации Trained-to-thrill, а в этом видео показано их влияние на производительность.
Кеширование: когда сохранять ресурсы #
Сервис-воркеры позволяют обрабатывать запросы независимо от кеширования, поэтому я продемонстрирую каждый из этих аспектов по отдельности. Во-первых, что касается кеширования: когда его следует выполнять?
При установке: в качестве зависимости #

Сервис-воркеры поддерживают событие 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()
будет отклонен.
В демонстрации Trained-to-thrill этот метод используется для кеширования статических ресурсов.
При установке: не в качестве зависимости #

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

Подходит для: очистки и миграции данных.
После того как новый сервис-воркер установлен и старый больше не используется, происходит активация нового сервис-воркера, сопровождающаяся событием activate
. Поскольку старая версия нам больше не мешает, самое время выполнить миграцию схемы в IndexedDB и удалить неиспользуемые кеши.
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames
.filter(function (cacheName) {
// Для удаления кеша необходимо вернуть true.
// Помните, что кеши являются общими
// для всего источника
})
.map(function (cacheName) {
return caches.delete(cacheName);
}),
);
}),
);
});
Во время активации другие события, такие как fetch
, помещаются в очередь, поэтому длительная активация потенциально может заблокировать загрузку страницы. Не перегружайте процесс активации и используйте его только для операций, которые невозможно выполнить во время работы старой версии.
В демонстрации Trained-to-thrill этот метод используется для удаления старых кешей.
В ответ на действие пользователя #

Подходит для: ситуаций, когда перевести весь сайт в офлайн невозможно и вы разрешили пользователю выбирать контент для офлайн-просмотра, такой как видео с 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 возвращает JSON-массив
// URL-адресов ресурсов, от которых зависит статья
return response.json();
})
.then(function (urls) {
cache.addAll(urls);
});
});
});
API-интерфейс кеширования доступен как из сервис-воркера, так и со страниц, поэтому для сохранения контента в кеш не обязательно использовать сервис-воркер.
При получении ответа по сети #

Подходит для: часто обновляемых ресурсов, таких как почтовый ящик пользователя или содержание статьи. Также подходит для необязательного контента, такого как аватары, однако в этом случае проявляйте осторожность.
Если запрашиваемый ресурс отсутствует в кеше, он загружается из сети, отправляется на страницу и одновременно записывается в кеш.
При загрузке большого числа URL-адресов, например аватаров, избегайте чрезмерного увеличения хранилища вашего источника (origin). Если пользователю понадобится освободить место на диске, нежелательно, чтобы ваш сайт стал первым кандидатом. Старайтесь удалять из кеша ресурсы, которые вам больше не нужны.
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()
дополнительные копии, которые можно читать по отдельности.
В демонстрации Trained-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;
});
}),
);
});
Аналогичным образом работает стратегия stale-while-revalidate в HTTP.
При получении 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') {
// Предположим, что все ресурсы, необходимые для
// рендеринга /inbox/, были кешированы ранее, например
// в рамках обработчика install.
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 и, разумеется, кешем.
Объем не является фиксированным и зависит от устройства, а также от условий хранения данных. Узнать его можно следующим образом:
navigator.storageQuota.queryInfo('temporary').then(function (info) {
console.log(info.quota);
// Результат: <квота в байтах>
console.log(info.usage);
// Результат: <используемые данные в байтах>
});
Однако, как и в случае с любым другим браузерным хранилищем, в случае нехватки места на устройстве ваши данные могут в любой момент быть удалены. К сожалению, браузер не сможет отличить фильмы, которые ни в коем случае нельзя удалять, от игры, до которой пользователю нет дела.
Чтобы обойти это ограничение, используйте интерфейс StorageManager:
// Со страницы:
navigator.storage.persist()
.then(function(persisted) {
if (persisted) {
// Ура, данные сохранятся!
} else {
// Гарантировать сохранение данных не получится.
});
Разумеется, пользователь должен будет предоставить разрешение. Для этого используйте Permissions API.
Важно, чтобы пользователь участвовал в этом процессе, поскольку теперь он сможет управлять удалением данных. Если на устройстве начнет заканчиваться место и удаление необязательных данных не поможет решить проблему, то именно пользователь будет решать, какие данные удалить, а какие оставить.
Для этого необходимо, чтобы при анализе расхода пространства операционные системы отображали «защищенные» хранилища наравне с нативными приложениями, а не объединяли все пространство, используемое браузером, в один пункт.
Советы по выдаче ресурсов: как обрабатывать запросы #
Неважно, сколько ресурсов вы храните в кеше: они не будут использоваться сервис-воркером, пока вы не определите условия для их использования. Вот несколько паттернов для обработки запросов:
Только кеш #

Подходит для: любого контента, который остается неизменным в рамках одной «версии» сайта. Такие ресурсы должны кешироваться во время события install, поэтому должны быть доступны в кеше.
self.addEventListener('fetch', function (event) {
// Если в кеше не будет найдено совпадение, то ответ
// будет выглядеть как ошибка соединения
event.respondWith(caches.match(event.request));
});
…однако обычно этот случай не требуется обрабатывать отдельно, поскольку для него подходит стратегия Кеш, в случае неудачи — сеть.
Только сеть #

Подходит для: запросов, не имеющих офлайн-эквивалента, таких как оповещения аналитики или запросы, отличные от GET.
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// или вообще не вызывайте event.respondWith; в этом
// случае используется стандартное поведение браузера
});
…однако обычно этот случай не требуется обрабатывать отдельно, поскольку для него подходит стратегия Кеш, в случае неудачи — сеть.
Кеш, в случае неудачи — сеть #

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

Подходит для: небольших ресурсов, при загрузке которых очень важна скорость на устройствах с медленным доступом к диску.
При использовании некоторых сочетаний устаревших жестких дисков, антивирусного ПО и широкополосных интернет-соединений загрузка ресурсов по сети происходит быстрее, чем обращение к диску. Однако имейте в виду, что загрузка по сети контента, который уже сохранен локально, может повысить расход трафика.
// Promise.race нам не подходит, так как отклоняется при
// отклонении невыполненного обещания. Давайте напишем
// свою функцию для одновременного выполнения:
function promiseAny(promises) {
return new Promise((resolve, reject) => {
// проверяем переданные promises
promises = promises.map((p) => Promise.resolve(p));
// если одно из обещаний выполняется, выполняем текущее
promises.forEach((p) => p.then(resolve));
// если все обещания отклонены, то отклоняем текущее
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 в основном придерживается линейного порядка контента. В демонстрации Trained-to-thrill я скопировал этот паттерн, для того чтобы как можно быстрее отображать контент на экране и в то же время обеспечить его оперативное обновление.
Код на странице:
var networkDataReceived = false;
startSpinner();
// загрузка свежих данных
var networkUpdate = fetch('/data.json')
.then(function (response) {
return response.json();
})
.then(function (data) {
networkDataReceived = true;
updatePage(data);
});
// загрузка кешированных данных
caches
.match('/data.json')
.then(function (response) {
if (!response) throw Error('No data');
return response.json();
})
.then(function (data) {
// не перезаписываем новые данные из сети
if (!networkDataReceived) {
updatePage(data);
}
})
.catch(function () {
// данные из кеша не получены; сеть - наша последняя надежда:
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;
});
}),
);
});
В демонстрации Trained-to-thrill я использовал для обхода ограничений XHR вместо fetch и приспособил заголовок Accept, чтобы сообщать сервис-воркеру, откуда следует загружать ресурс (код страницы, код сервис-воркера).
Резервные ресурсы #

Когда загрузить ресурс из кеша или из сети не удается, есть смысл предоставить резервный вариант.
Подходит для: второстепенных изображений, таких как аватары; неудачных запросов POST; страниц, сообщающих о недоступности данных в офлайн-режиме.
self.addEventListener('fetch', function (event) {
event.respondWith(
// Пробуем загрузку из кеша
caches
.match(event.request)
.then(function (response) {
// В случае неудачи обращаемся к сети
return response || fetch(event.request);
})
.catch(function () {
// Если оба запроса завершились неудачно,
// показываем резервный ресурс:
return caches.match('/offline.html');
// У вас может быть много резервных ресурсов,
// выбираемых на основе URL-адреса и заголовков,
// например картинка силуэта лица для аватаров.
}),
);
});
В качестве резервных следует указывать ресурсы, являющиеся зависимостями при установке.
Если ваша страница отправляет письмо, то в случае неудачи сервис-воркер может сохранить его в папке «Исходящие» в IndexDB и уведомить об этом пользователя.
Разметка на стороне сервис-воркера #

Подходит для: страниц, для которых невозможно кешировать ответ с сервера.
Рендеринг страниц на сервере ускоряет работу сайта, однако страницы могут включать динамические данные, для которых кеширование неуместно, такие как имя текущего пользователя. Если страница контролируется сервис-воркером, вы можете запрашивать данные в виде 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-адреса запроса. Например, в Trained-to-thrill используются следующие методы:
- кеширование при установке для статических элементов интерфейса и логики;
- кеширование при получении ответа по сети для изображений и данных Flickr;
- загрузка из кеша, а в случае неудачи — из сети для большинства запросов;
- загрузка из кеша, а затем из сети для результатов поиска Flickr.
Стратегию следует выбирать исходя из типа запроса:
self.addEventListener('fetch', function (event) {
// Разбор URL-адреса:
var requestURL = new URL(event.request.url);
// Обработка запросов к конкретному домену особым образом
if (requestURL.hostname == 'api.example.com') {
event.respondWith(/* комбинация паттернов */);
return;
}
// Маршрутизация для локальных URL-адресов
if (requestURL.origin == location.origin) {
// Обработка URL-адресов статей
if (/^\/article\//.test(requestURL.pathname)) {
event.respondWith(/* другая комбинация паттернов */);
return;
}
if (/\.webp$/.test(requestURL.pathname)) {
event.respondWith(/* другая комбинация паттернов */);
return;
}
if (request.method == 'POST') {
event.respondWith(/* другая комбинация паттернов */);
return;
}
if (/cheese/.test(requestURL.pathname)) {
event.respondWith(
new Response('Очень страшная ошибка', {
status: 512,
}),
);
return;
}
}
// Паттерн по умолчанию
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
…и так далее.
Благодарности #
Авторы иконок:
- Код: buzzyrobot
- Календарь: Скотт Льюис
- Сеть: Бен Риццо
- SD-карта: Томас Ле Бас
- Процессор: iconsmind.com
- Корзина для мусора: trasnik
- Уведомление: @daosme
- Макет сайта: Mister Pixel
- Облако: П. Дж. Онори
Кроме того, спасибо Джеффу Поснику за исправление множества ошибок в статье.
Материалы для дальнейшего чтения #
- Сервис-воркеры: введение
- Is Service Worker ready? — отслеживание состояния реализации в основных браузерах
- Обещания JavaScript: введение — руководство по обещаниям