Количество выходов в интернет с мобильных устройств ежегодно растёт на 2-4% в год. Качество связи не успевает за такими темпами. Как итог, даже самое лучшее веб-приложение обеспечит ужасный опыт, если пользователь не сможет его загрузить.
Проблема в том, что до сих пор нет хорошего механизма управления кэшем ресурсов и результатов сетевых запросов. В своей статье я хочу рассказать как Service Worker (SW) может помочь в решении этой задачи. Объясню в формате рецептов — какие элементы и в какой пропорции смешать, чтобы получить нужный результат, исходя из задачи и требований.
До появления SW проблему работы в offline-режиме решал другой API — AppCache. Однако наряду с подводными камнями AppCache фигурировал факт, что он отлично работает в single-page приложениях, но не очень хорошо подходит для многостраничных сайтов. SW разрабатывались, чтобы избежать этих проблем.
Что такое Service Worker?
Во-первых, это скрипт, который браузер запускает в фоновом режиме, отдельно от страницы, открывая дверь для возможностей, не требующих веб-страницы или взаимодействия с пользователем. Сегодня они выполняют такие функции как push-уведомления и фоновая синхронизация, в будущем SW будут поддерживать и другие вещи. Ключевая их особенность — это возможность перехватывать и обрабатывать сетевые запросы, включая программное управление кэшированием ответов.
Во-вторых, SW запускается в worker контексте, поэтому он не имеет доступа к DOM и работает в потоке, отдельном от основного потока JavaScript, управляющего вашим приложением, а следовательно — не блокирует его. Он призван быть полностью асинхронным, поэтому использовать синхронные API (XHR и LocalStorage) в SW нельзя.
В-третьих, из соображений безопасности SW работают только по HTTPS, так как давать посторонним людям возможность изменять сетевые запросы крайне опасно.
Что нужно кэшировать?
Для того чтобы приложение стало более отзывчивым, нам нужно кэшировать все статичные файлы:
- js (vendor, chunks)
- css
- медиаконтент (фото, видео, шрифты)
Почему мы не можем использовать LocalStorage для подобной ситуации?
Всё очень просто. LocalStorage — синхронный API, имеет ограничение в 5MB и позволяет хранить только строки.
У SW с этим всё лучше: он асинхронный, является прокси для запросов, что позволяет обрабатывать и кэшировать любой запрос и согласно статье Offline Storage for Progressive Web Apps от Эдди Османи:
- Chrome/Opera: оба хранилища будут хранить данные, пока не достигнут ограничения браузера. Фактически это безграничное пространство (подробнее в Quota Management API и Persistent Storage).
- Firefox: нет ограничений, подтверждение после 50MB.
- Mobile Safari: ограничение в 50MB.
- Desktop Safari: нет ограничений, подтверждение после 5MB.
- IE10+: максимум 250MB и подтверждение при 10MB.
Мне уже нравится Service Worker. Как его использовать?
Ниже я расскажу про рецепты приготовления SW для создания отзывчивых и понятных приложений.
Заготовки для приготовления Service Workers
Для написания своего собственного SW нам понадобятся:
- index.html
- index.js
- sw.js
Всё, что нужно сделать, это в index.html подключить index.js, в котором будет происходить регистрация файла sw.js
// Проверка того, что наш браузер поддерживает Service Worker API.
if ('serviceWorker' in navigator) {
// Весь код регистрации у нас асинхронный.
navigator.serviceWorker.register('./sw.js')
.then(() => navigator.serviceWorker.ready.then((worker) => {
worker.sync.register('syncdata');
}))
.catch((err) => console.log(err));
}
В файле sw.js нам нужно лишь определить базовые события, на которые будет реагировать SW.
self.addEventListener('install', (event) => {
console.log('Установлен');
});
self.addEventListener('activate', (event) => {
console.log('Активирован');
});
self.addEventListener('fetch', (event) => {
console.log('Происходит запрос на сервер');
});
Подробности про lifecycle для SW вы можете узнать из данной статьи.
Рецепт №1 — Network or cache
Подходит для медийных сайтов, где очень много медиа-контента. Картинки и видео могут отдаваться долго, наша задача закэшировать их. При последующих запросах на сервер мы будем отдавать данные из кэша. Имеем в виду, что данные могут быть уже неактуальными, для нас главное здесь — избавить пользователя от ожидания загрузки файлов.
Решение
Данный вариант подойдёт, если скорость загрузки контента для вас приоритетна, но хотелось бы показать наиболее актуальные данные.
Механизм работы следующий: идёт запрос на ресурс с ограничением по времени, например 400ms, если данные не были получены в течении этого времени, мы отдаём их из кэша.
SW в этом рецепте пытается получить самый актуальный контент из сети, но если запрос занимает слишком много времени, то данные будут взяты из кэша. Эту проблему можно решить путём выставления timeout’а на запрос.
const CACHE = 'network-or-cache-v1';
const timeout = 400;
// При установке воркера мы должны закешировать часть данных (статику).
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll([
'/img/background'
])
));
});
// при событии fetch, мы и делаем запрос, но используем кэш, только после истечения timeout.
self.addEventListener('fetch', (event) => {
event.respondWith(fromNetwork(event.request, timeout)
.catch((err) => {
console.log(`Error: ${err.message()}`);
return fromCache(event.request);
}));
});
// Временно-ограниченный запрос.
function fromNetwork(request, timeout) {
return new Promise((fulfill, reject) => {
var timeoutId = setTimeout(reject, timeout);
fetch(request).then((response) => {
clearTimeout(timeoutId);
fulfill(response);
}, reject);
});
}
function fromCache(request) {
// Открываем наше хранилище кэша (CacheStorage API), выполняем поиск запрошенного ресурса.
// Обратите внимание, что в случае отсутствия соответствия значения Promise выполнится успешно, но со значением `undefined`
return caches.open(CACHE).then((cache) =>
cache.match(request).then((matching) =>
matching || Promise.reject('no-match')
));
}
Рецепт №2 — Cache only
Идеальный рецепт для лендингов, задача которых — продемонстрировать пользователю продукт и тем самым задержать его внимание на сайте. Медленная загрузка контента при плохом соединении в данном случае просто неприемлема, поэтому приоритет данного рецепта — отдача данных из кэша при любом соединении. Исключение — первый запрос и чистка кэша. Минус способа в том, что если вы измените контент, то у пользователей перемена произойдет после того, как кэш станет невалидным. По умолчанию SW делают перерегистрацию через 24 часа после установки.
Решение
Всё, что мы делаем, это при регистрации SW складываем в кэш все наши статичные ресурсы; при последующих обращениях к ресурсам SW всегда будет отвечать данными из кэша.
const CACHE = 'cache-only-v1';
// При установке воркера мы должны закешировать часть данных (статику).
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => {
return cache.addAll([
'/img/background'
]);
})
);
});
// При запросе на сервер (событие fetch), используем только данные из кэша.
self.addEventListener('fetch', (event) =>
event.respondWith(fromCache(event.request));
);
function fromCache(request) {
return caches.open(CACHE).then((cache) =>
cache.match(request)
.then((matching) => matching || Promise.reject('no-match'))
);
}
Рецепт №3 — Cache and update
Данный рецепт решает проблему актуальности данных, чего не было в рецепте №2.
Иными словами мы получим обновлённый контент, но с задержкой до следующей загрузки страницы.
Решение
Как и в предыдущем варианте, в данном рецепте SW сначала отвечает из кэша, чтобы доставить быстрые ответы, но при этом обновляет данные кэша из сети.
const CACHE = 'cache-and-update-v1';
// При установке воркера мы должны закешировать часть данных (статику).
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) =>
cache.addAll(['/img/background']))
);
});
// при событии fetch, мы используем кэш, и только потом обновляем его данным с сервера
self.addEventListener('fetch', function(event) {
// Мы используем `respondWith()`, чтобы мгновенно ответить без ожидания ответа с сервера.
event.respondWith(fromCache(event.request));
// `waitUntil()` нужен, чтобы предотвратить прекращение работы worker'a до того как кэш обновиться.
event.waitUntil(update(event.request));
});
function fromCache(request) {
return caches.open(CACHE).then((cache) =>
cache.match(request).then((matching) =>
matching || Promise.reject('no-match')
));
}
function update(request) {
return caches.open(CACHE).then((cache) =>
fetch(request).then((response) =>
cache.put(request, response)
)
);
}
Рецепт №4 — Cache, update and refresh
Расширение рецепта №3. В данном решении мы обновляем контент в фоне, но всегда можем указать пользователю, что данные на странице поменялись. Примером может служить создание приложений, в которых происходит редактирование контента в фоне. Так, вы читаете статью на новостом сайте и получаете уведомление о том, что данные на странице обновились и появилась более свежая информация.
Решение
Рецепт позволяет SW отвечать из кэша, чтобы отдавать быстрые ответы, а также обновлять данные в кэше из сети. Когда запрос выполнится успешно, пользовательский интерфейс будет обновлён автоматически или посредством UI-контрола.
Используйте содержимые данные из кэша, но в то же время выполняйте запрос на обновление записи кэша и информируйте UI о новый данных.
const CACHE = 'cache-update-and-refresh-v1';
// При установке воркера мы должны закешировать часть данных (статику).
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(['/img/background']))
);
});
// При запросе на сервер мы используем данные из кэша и только после идем на сервер.
self.addEventListener('fetch', (event) => {
// Как и в предыдущем примере, сначала `respondWith()` потом `waitUntil()`
event.respondWith(fromCache(event.request));
event.waitUntil(
update(event.request)
// В конце, после получения "свежих" данных от сервера уведомляем всех клиентов.
.then(refresh)
);
});
function fromCache(request) {
return caches.open(CACHE).then((cache) =>
cache.match(request).then((matching) =>
matching || Promise.reject('no-match')
));
}
function update(request) {
return caches.open(CACHE).then((cache) =>
fetch(request).then((response) =>
cache.put(request, response.clone()).then(() => response)
)
);
}
// Шлём сообщения об обновлении данных всем клиентам.
function refresh(response) {
return self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
// Подробнее про ETag можно прочитать тут
// https://en.wikipedia.org/wiki/HTTP_ETag
const message = {
type: 'refresh',
url: response.url,
eTag: response.headers.get('ETag')
};
// Уведомляем клиент об обновлении данных.
client.postMessage(JSON.stringify(message));
});
});
}
Рецепт №5 — Embedded fallback
Существует проблема, когда браузер по умолчанию выдаёт вам сообщение о том, что вы офлайн. Я называю это проблемой, так как:
- Экран отличается от вашего приложения.
- Экран выглядит по-разному в каждом браузере.
- Сообщение не может быть локализовано.
Лучшим решением в данной ситуации было бы показать пользователю пользовательский фрагмент автономного кэша. С помощью SW мы можем подготовить заранее заготовленный ответ, говорящий о том, что приложение вне сети и его функционал на определенное время ограничен.
Решение
Нужно отдать fallback-данные, если нет доступа к ресурсам (сеть и кэш).
Данные подготавливаются заранее и кладутся как статичные ресурсы, доступные SW.
const CACHE = 'offline-fallback-v1';
// При установке воркера мы должны закешировать часть данных (статику).
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(['/img/background']))
// `skipWaiting()` необходим, потому что мы хотим активировать SW
// и контролировать его сразу, а не после перезагрузки.
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
// `self.clients.claim()` позволяет SW начать перехватывать запросы с самого начала,
// это работает вместе с `skipWaiting()`, позволяя использовать `fallback` с самых первых запросов.
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', function(event) {
// Можете использовать любую стратегию описанную выше.
// Если она не отработает корректно, то используейте `Embedded fallback`.
event.respondWith(networkOrCache(event.request)
.catch(() => useFallback()));
});
function networkOrCache(request) {
return fetch(request)
.then((response) => response.ok ? response : fromCache(request))
.catch(() => fromCache(request));
}
// Наш Fallback вместе с нашим собсвенным Динозавриком.
const FALLBACK =
'<div>n' +
' <div>App Title</div>n' +
' <div>you are offline</div>n' +
' <img src="/svg/or/base64/of/your/dinosaur" alt="dinosaur"/>n' +
'</div>';
// Он никогда не упадет, т.к мы всегда отдаем заранее подготовленные данные.
function useFallback() {
return Promise.resolve(new Response(FALLBACK, { headers: {
'Content-Type': 'text/html; charset=utf-8'
}}));
}
function fromCache(request) {
return caches.open(CACHE).then((cache) =>
cache.match(request).then((matching) =>
matching || Promise.reject('no-match')
));
}
Заключение
Выше мы рассмотрели базовые рецепты применения SW для приложений.
Они описаны по мере усложнения. Если у вас простой лендинг — не нужно лезть в дебри, просто используйте Cache only или Network or cache. Для более сложных приложений используйте остальные рецепты.
Статья задумывалась начальной в серии статей о SW API. Хочется понять, насколько тема интересна и полезна. Жду ваших комментариев и пожеланий.
Автор: Андрей Михайлов