Офлайн-поваренная книга

С помощью Service Worker мы отказались от попыток решить проблему в автономном режиме и предоставили разработчикам движущиеся части, чтобы они могли решить эту проблему самостоятельно. Это дает вам контроль над кэшированием и обработкой запросов. Это означает, что вы можете создавать свои собственные шаблоны. Давайте рассмотрим несколько возможных шаблонов по отдельности, но на практике вы, скорее всего, будете использовать многие из них в тандеме в зависимости от URL-адреса и контекста.

Рабочую демонстрацию некоторых из этих шаблонов см. в разделе Trained-to-thrill и в этом видеоролике, показывающем влияние на производительность.

Service Worker позволяет обрабатывать запросы независимо от кэширования, поэтому я продемонстрирую это отдельно. Прежде всего, кеширование: когда это следует делать?

При установке — как зависимость

При установке - как зависимость.
При установке - как зависимость.

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

Идеально подходит для: 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 берет на себя обязательство определить продолжительность и успешность установки. Если обещание отклоняется, установка считается неудачной, и этот Service Worker будет закрыт (если работает более старая версия, она останется нетронутой). 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
       
// levels 11–20
       
();
     
return cache
       
.addAll
       
// core assets and levels 1–10
       
();
   
}),
 
);
});

В приведенном выше примере обещание cache.addAll для уровней 11–20 не передается обратно в event.waitUntil , поэтому даже в случае сбоя игра все равно будет доступна в автономном режиме. Конечно, вам придется учесть возможное отсутствие этих уровней и повторить попытку их кэширования, если они отсутствуют.

Service Worker может быть убит во время загрузки уровней 11–20, поскольку он завершил обработку событий, то есть они не будут кэшироваться. В будущем API периодической фоновой синхронизации веб-страниц будет обрабатывать подобные случаи, а также большие загрузки, например фильмы. Этот API в настоящее время поддерживается только в версиях Chromium.

При активации

При активации.
При активации.

Идеально подходит для: очистки и миграции.

Как только новый Service Worker установлен и предыдущая версия не используется, активируется новый, и вы получаете событие 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() для создания дополнительных копий, которые можно читать отдельно.

На тренировках я использую это для кэширования изображений 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.

В push-сообщении

В push-сообщении.
В push-сообщении.

Push API — еще одна функция, созданная на основе Service Worker. Это позволяет Service Worker просыпаться в ответ на сообщение от службы обмена сообщениями ОС. Это происходит, даже если у пользователя нет открытой вкладки вашего сайта. Проснулся только сервисный работник. Вы запрашиваете разрешение на это со страницы, и пользователю будет предложено.

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

Обычным конечным результатом является уведомление, при нажатии которого открывается или фокусируется соответствующая страница, но для которого обновление кэшей до того, как это произойдет, чрезвычайно важно. Очевидно, что пользователь находится в сети в момент получения 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/');
 
}
});

При фоновой синхронизации

На фоновой синхронизации.
На фоновой синхронизации.

Фоновая синхронизация — еще одна функция, созданная на основе Service Worker. Он позволяет запрашивать фоновую синхронизацию данных однократно или через (чрезвычайно эвристический) интервал. Это происходит, даже если у пользователя нет открытой вкладки вашего сайта. Проснулся только сервисный работник. Вы запрашиваете разрешение на это со страницы, и пользователю будет предложено.

Идеально подходит для: несрочных обновлений, особенно тех, которые происходят настолько регулярно, что 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');
     
}),
   
);
 
}
});

Постоянство кэша

Вашему источнику предоставляется определенное количество свободного места, с которым он может делать все, что хочет. Это свободное пространство распределяется между всеми исходными хранилищами: (локальными) Storage , IndexedDB , File System Access и, конечно же, Caches .

Сумма, которую вы получите, не указана. Он будет отличаться в зависимости от устройства и условий хранения. Узнать сумму, которую вы получили, можно через:

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));
});

…хотя вам не часто нужно специально обрабатывать этот случай, Cache, возвращаясь к сети, покрывает его.

Только сеть

Только сеть.
Только сеть.

Идеально подходит для: вещей, которые не имеют автономного эквивалента, таких как аналитические пинги, запросы без GET.

self.addEventListener('fetch', function (event) {
  event
.respondWith(fetch(event.request));
 
// or simply don't call event.respondWith, which
 
// will result in default browser behavior
});

…хотя вам не часто нужно специально обрабатывать этот случай, Cache, возвращаясь к сети, покрывает его.

Кэш, возврат к сети

Кэш, возвращение в сеть.
Кэш, возвращение в сеть.

Идеально подходит для создания оффлайн-проектов. В таких случаях именно так вы будете обрабатывать большинство запросов. Другие шаблоны будут исключениями в зависимости от входящего запроса.

self.addEventListener('fetch', function (event) {
  event
.respondWith(
    caches
.match(event.request).then(function (response) {
     
return response || fetch(event.request);
   
}),
 
);
});

Это дает вам поведение «только кэш» для вещей в кеше и поведение «только сеть» для всего, что не кэшируется (включая все запросы, не относящиеся к GET, поскольку они не могут быть кэшированы).

Кэш и сетевая гонка

Кэш и сетевая гонка.
Кэш и сетевая гонка.

Идеально подходит для: небольших ресурсов, где вам нужна производительность на устройствах с медленным доступом к диску.

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

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// 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 в основном сохраняет линейный порядок содержания. Я скопировал этот шаблон для тренировки, чтобы как можно быстрее выводить контент на экран и отображать актуальный контент сразу по его появлению.

Код на странице:

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);

Код в Service Worker:

Вы всегда должны выходить в сеть и обновлять кеш по ходу дела.

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;
     
});
   
}),
 
);
});

В обучении к трепету я обошел эту проблему, используя XHR вместо fetch и злоупотребляя заголовком Accept, чтобы сообщить Service Worker, откуда получить результат ( код страницы , код Service Worker ).

Общий запасной вариант

Общий запасной вариант.
Общий запасной вариант.

Если вам не удается обслужить что-либо из кеша и/или сети, вы можете предоставить общий запасной вариант.

Идеально подходит для: вторичных изображений, таких как аватары, неудачных запросов 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 и ответить, сообщив странице, что отправка не удалась, но данные были успешно сохранены.

Шаблоны на стороне рабочей службы службы

Шаблоны на стороне ServiceWorker.
Шаблоны на стороне ServiceWorker.

Идеально подходит для: страниц, ответы на которые не могут быть кэшированы.

Рендеринг страниц на сервере ускоряет работу , но это может означать включение в кэш данных о состоянии, которые могут не иметь смысла, например «Вошел в систему как…». Если ваша страница контролируется сервисным работником, вы можете вместо этого запросить данные 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-адреса запроса. Например, тренинг для острых ощущений использует:

Просто посмотрите на запрос и решите, что делать:

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);
   
}),
 
);
});

… вы поняли.

Кредиты

…за прекрасные значки:

И спасибо Джеффу Поснику за то, что он обнаружил множество серьезных ошибок еще до того, как я нажал «опубликовать».

Дальнейшее чтение