Использование Service Worker

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

Предпосылки появления Service Workers

Одной из важнейших проблем, от которой страдали пользователи веб-приложений, была работа в условиях потери связи. Лучшее в мире веб-приложение оставит ужасное впечатление от использования, если пользователь не сможет его загрузить. Предпринималось много попыток создания технологий, которые бы решили эту проблему, и отчасти это удалось. Но всё же наиважнейшей проблемой по-прежнему является отсутствие хорошего механизма для управления кешем ресурсов и настраиваемыми сетевыми запросами.

Что нужно настроить, чтобы поработать с Service Worker

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

  • Firefox Nightly: Перейдите в раздел about:config и установите параметр dom.serviceWorkers.enabled в значение true; затем перезапустите браузер.
  • Chrome Canary: Перейдите в раздел chrome://flags и включите experimental-web-platform-features; перезапустите браузер (заметьте, что некоторые функции теперь включены по умолчанию в браузере Chrome.)
  • Opera: Перейдите в раздел opera://flags и включите Support for ServiceWorker; перезапустите браузер.
  • Microsoft Edge: Перейдите в раздел about:flags и поставьте галочку Enable service workers; перезапустите браузер.

Также вам необходимо предоставлять ваш код по протоколу HTTPS — Service Worker требует этого по соображениям безопасности. По этой причине GitHub — хороший выбор для экспериментов, поскольку он поддерживает протокол HTTPS по умолчанию. Для облегчения локальной разработки браузеры считают localhost также безопасным origin.

Базовая архитектура

Чтобы сделать базовую настройку Service Worker, как правило, нужно пройти следующие шаги:

  1. URL сервис-воркера опрашивается и регистрируется посредством вызова метода ServiceWorkerContainer.register().
  2. Если регистрация прошла успешно, то сервис-воркер начинает работать внутри ServiceWorkerGlobalScope; это, по сути, особый вид контекста воркера, работающий вне главного потока браузера, без доступа к DOM.
  3. Теперь сервис-воркер может обрабатывать события.
  4. Установка сервис-воркера начинается после того, как все контролируемые им страницы закешированы и доступны для последующего использования. Событие install всегда посылается первым воркеру (оно может быть использовано для запуска начальной загрузки данных в IndexedDB, для кеширования ресурсов). Данный этап сродни процедуре установки нативного или FirefoxOS-приложения — все делается доступным для использования в офлайн-режиме.
  5. Как только обработчик события oninstall завершит свою работу, сервис-воркер считается установленным.
  6. Далее следует активация. После того как воркер установлен, он получает событие onactivate, которое обычно используется для очистки ресурсов, задействованных в предыдущей версии скрипта сервис-воркера.
  7. Сервис-воркер здесь может контролировать страницы, но только в случае, если те открыты после успешного вызова register(). То есть документ может начать жизнь с сервис-воркером или даже без него и продолжать нормально работать. Поэтому документы должны быть перезагружены, чтобы действительно быть подконтрольными сервис-воркеру.

Диаграмма жизненного цикла сервис-воркера

Список доступных событий сервис-воркеров:

Демонстрация Service Workers

Чтобы продемонстрировать только базовые моменты регистрации и установки сервис-воркеров, мы создали простое демо-приложение, названое sw-test. Это простая галерея изображений "Star wars Lego". Оно использует промис-функции, чтобы прочитать из JSON-объекта и загрузить, используя технологию Ajax, изображения, находящиеся далее нижнего края страницы, до того как они будут показаны. В приложении также ещё регистрируется, устанавливается и активируется сервис-воркер, и, в случае если браузер поддерживает спецификацию Service Worker, запрашиваемые ресурсы будут закешированы, и приложение будет работать в офлайн-режиме!

Вы можете посмотреть исходный код на GitHub, а также этот живой пример. Единственное, что мы тут рассмотрим, это промис (смотрите app.js строки 22-47), модифицированная версия того, о котором вы читали выше в разделе Тестовая демонстрация промисов. Разница в следующем:

  1. Ранее мы передавали параметром лишь URL изображения, которое мы хотели загрузить. Теперь же, мы передаём JSON-фрагмент, содержащий все данные для изображения (посмотрите, как это выглядит в image-list.js). Это сделано потому, что все данные для выполнения каждого промиса должны быть переданы ему, так как он выполняется асинхронно. Если же вы передали лишь URL, а чуть позже попытались получить доступ к другим атрибутам в JSON-фрагменте внутри цикла for(), это бы не сработало, так как этот промис не был бы выполнен во время текущей итерации цикла (это синхронный процесс).
  2. Теперь мы выполняем промис с параметром-массивом, так как дальше мы хотим сделать загруженные данные изображения доступными для разрешающей функции, одновременно с именем файла, данными авторства и замещающим текстом (см. app.js строки 31-34). Промисы будут выполняться со всего одним аргументом, поэтому, если вы хотите выполнить их с несколькими параметрами, вы должны использовать массив/объект.
  3. Затем, чтобы получить доступ к выполненным значениям промисов, мы обращаемся к ним так, как было задумано (смотрите app.js строки 60-64). По началу это может выглядеть немного странно, но именно так и работают промисы.

Погружение в Service Worker

Итак, переходим к Service Worker!

Регистрация воркеров

Ниже представлен первый блок кода файла app.js. Это точка входа в Service Worker.

js
if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("./sw-test/sw.js", { scope: "./sw-test/" })
    .then((reg) => {
      // регистрация сработала
      console.log("Registration succeeded. Scope is " + reg.scope);
    })
    .catch((error) => {
      // регистрация прошла неудачно
      console.log("Registration failed with " + error);
    });
}
  1. Внешний условный блок выполняет проверку на поддержку Service Worker, чтобы убедиться что технология доступна, до того как начать регистрацию.
  2. Далее, чтобы зарегистрировать сервис-воркера для этого сайта, мы используем функцию ServiceWorkerContainer.register(). Сервис-воркер представляет собой JavaScript-файл приложения (обратите внимание, что URL указывается относительно "корня", а не места расположения JS-файла, регистрирующего сервис-воркер).
  3. Параметр scope - не обязателен, он может быть использован для указания подмножества контента, которое вы хотите отдать под контроль сервис-воркера. В нашем случае, мы указали './sw-test/'. Если вы не укажете его, то будет использовано значение по умолчанию; мы же указали его только в целях иллюстрации.
  4. Метод .then() был использован для обработки успешной регистрации. Если промис разрешится успешно, то код, переданный этому методу, будет выполнен.
  5. Ну и наконец, в конец нашего промиса мы добавляем функцию .catch(), которая будет выполнена в случае, если промис будет отклонён.

Предыдущий код регистрирует сервис-воркера, который работает в worker-контексте, и следовательно, не имеет доступа к DOM. Затем вы запускаете код в сервис-воркере, вне ваших страниц, чтобы контролировать их загрузку.

Один сервис-воркер может контролировать несколько страниц. Каждый раз, когда загружается страница, находящаяся в пределах области видимости, сервис-воркер будет установлен на ней и начнёт работу. Поэтому будьте осторожны с применением глобальных переменных в скриптах сервис-воркеров, потому как у каждой страницы нет своего уникального экземпляра сервис-воркера.

Почему мой сервис-воркер не прошёл регистрацию?

Это может произойти по следующим причинам:

  1. Приложение загружено не по протоколу HTTPS.
  2. Путь к сервис-воркеру указан некорректно — он должен быть написан относительно origin запроса, а не вашей корневой директории с приложением. В нашем примере воркер расположен в https://mdn.github.io/sw-test/sw.js, корневая папка — https://mdn.github.io/sw-test/. Но в качестве пути к сервис-воркеру нужно указывать /sw-test/sw.js, а не /sw.js.
  3. Origin сервис-воркера отличается от origin вашего приложения. Это также запрещено.

Также обратите внимание:

  • В сервис-воркер будут попадать только те запросы, которые соответствуют его области видимости.
  • Максимальная область видимость сервис-воркера равна его location.
  • Если ваш сервис-воркер работает на клиенте, которому был передан заголовок Service-Worker-Allowed, вы можете указать список максимальных областей видимости для этих воркеров.

Установка и активация: заполнение кеша

После того как ваш сервис-воркер будет зарегистрирован, браузер может попробовать установить его и активировать на странице/сайте.

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

Давайте начнём этот раздел посмотрев на фрагмент кода ниже — это первый блок кода, который вы увидите в нашем сервис-воркере:

js
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("v1").then((cache) => {
      return cache.addAll([
        "./sw-test/",
        "./sw-test/index.html",
        "./sw-test/style.css",
        "./sw-test/app.js",
        "./sw-test/image-list.js",
        "./sw-test/star-wars-logo.jpg",
        "./sw-test/gallery/",
        "./sw-test/gallery/bountyHunters.jpg",
        "./sw-test/gallery/myLittleVader.jpg",
        "./sw-test/gallery/snowTroopers.jpg",
      ]);
    }),
  );
});
  1. Здесь мы добавляем обработчик события install к сервис-воркеру (отныне self), и затем вызываем метод ExtendableEvent.waitUntil() объекта события. Такая конструкция гарантирует, что сервис-воркер не будет установлен, пока код, переданный внутри waitUntil(), не завершится с успехом.
  2. Внутри waitUntil() мы используем метод caches.open(), чтобы создать новый кеш, который назовём v1, это будет первая версия кеша ресурсов. Этот метод возвращает промис для созданного кеша; когда он выполнится, у объекта созданного кеша мы вызовем метод addAll(), который в качестве параметра ожидает получить массив origin-относительных URL всех ресурсов, которые мы хотим хранить в кеше.
  3. Если промис будет отклонён, то установка будет завершена неудачно, и воркер ничего не сделает. Это хорошо, потому как вы можете исправить свой код и затем попробовать провести регистрацию в следующий раз.
  4. После успешной установки сервис-воркер активируется. Этот момент не очень важен при первоначальной установке/активации сервис-воркера, в то же время он имеет большое значение, когда происходит обновление воркера (смотрите раздел Обновление вашего сервис-воркера, находящийся ниже).

Настраиваемые ответы на запросы

Теперь ресурсы вашего сайта находятся в кеше и вам необходимо указать сервис-воркеру, что делать с этим контентом. Это легко сделать, обработав событие fetch.

Диаграмма события fetch

Событие fetch возникает каждый раз, когда запрашиваются любые подконтрольные сервис-воркеру ресурсы, к которым относятся документы из области видимости и другие ресурсы, связанные с этими документами (например, если в index.html происходит кросс-доменный запрос для загрузки изображения, то он тоже попадёт в сервис-воркер).

Вы можете подключить к сервис-воркеру обработчик события fetch и внутри него на объекте события вызвать метод respondWith(), чтобы заменить ответы и показать собственную "магию".

js
self.addEventListener("fetch", (event) => {
  event
    .respondWith
    // магия происходит здесь
    ();
});

Для начала, на каждый сетевой запрос мы можем отдать в ответ ресурс, чей url соответствует запросу:

js
self.addEventListener("fetch", (event) => {
  event.respondWith(caches.match(event.request));
});

caches.match(event.request) позволяет нам проверять сетевой запрос ресурса на соответствие какому-либо доступному в кеше ресурсу, если такой ресурс имеется. Соответствие проверяется по url и изменяемым заголовкам.

Давайте рассмотрим несколько других вариантов реализации нашей магии (чтобы получить больше информации об интерфейсах Request и Response смотрите документацию к Fetch API.)

  1. Конструктор Response() позволяет вам создавать собственные ответы. В данном случае, мы всего лишь возвращаем простую текстовую строку:

    js
    new Response("Hello from your friendly neighbourhood service worker!");
    

    В этом более сложном объекте Response показано, как вы можете передать набор заголовков в свой ответ, эмулируя стандартный HTTP-ответ. Здесь мы просто сообщаем браузеру, чем является содержимое ответа:

    js
    new Response(
      "<p>Hello from your friendly neighbourhood service worker!</p>",
      {
        headers: { "Content-Type": "text/html" },
      },
    );
    
  2. Если совпадение не было найдено в кеше, вы можете попросить браузер загрузить тот же ресурс, чтобы получить новый файл через обычную сеть, если она доступна:

    js
    fetch(event.request);
    
  3. Если информация, соответствующая запросу, в кеше не найдена, а также сеть не доступна, то вы можете просто ответить на запрос какой-либо страницей по умолчанию, которая хранится в кеше, используя match():

    js
    caches.match("./fallback.html");
    
  4. Вы можете получить больше информации о каждом запросе, используя для этого свойства объекта Request, который можно получить как свойство объекта FetchEvent:

    js
    event.request.url;
    event.request.method;
    event.request.headers;
    event.request.body;
    

Восстановление неудачных запросов

Итак, caches.match(event.request) отработает как нужно только в том случае, если в кеше сервис-воркера будет найдено соответствие запросу. Но что произойдёт, если такого соответствия не будет найдено? Если мы не предоставим никакого механизма обработки такой ситуации, то промис выполнится со значением undefined и мы не получим никакого значения.

К счастью, сервис-воркеры имеют структуру основанную на промисах, что делает тривиальной такую обработку и предоставляет большое количество способов успешно обработать запрос:

js
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    }),
  );
});

Если промис будет отклонён, функция catch() вернёт обычный сетевой запрос к внешнему ресурсу. Это значит, что, если сеть доступна, то ресурс просто загрузится с сервера.

Если же мы были достаточно умны, то мы не стали бы просто возвращать сетевой запрос, а сохранили бы его результат в кеше, чтобы иметь возможность получить его в офлайн-режиме. В случае с нашим демо-приложением "Star Wars gallery", это означает, что, если в галерею будет добавлено ещё одно изображение, то оно будет получено и сохранено в кеше:

js
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((resp) => {
      return (
        resp ||
        fetch(event.request).then((response) => {
          return caches.open("v1").then((cache) => {
            cache.put(event.request, response.clone());
            return response;
          });
        })
      );
    }),
  );
});

Здесь мы возвращаем обычный сетевой запрос, который возвращён вызовом fetch(event.request); этот запрос также является промисом. Когда промис разрешится, мы получим кеш вызвав caches.open('v1'); этот метод также возвращает промис. Когда разрешится уже второй промис, будет использован вызов cache.put(), чтобы поместить ресурс в кеш. Ресурс получен через event.request, а ответ — через клонирование response.clone(). Клон помещается в кеш, а оригинальный ответ передаётся браузеру, который передаёт его странице, которая запросила ресурс.

Почему? Потому что потоки запроса и ответа могут быть прочитаны только единожды. Чтобы ответ был получен браузером и сохранён в кеше, нам нужно клонировать его. Так оригинальный объект отправится браузеру, а клон будет закеширован. Оба они будут прочитаны единожды.

У нас все ещё остаётся единственная проблема - если на какой-либо запрос в кеше не будет найдено соответствие, и в этот момент сеть не доступна, то наш запрос завершится неудачно. Давайте реализуем запасной вариант по умолчанию, при котором пользователь, в описанном случае, будет получать хоть что-нибудь:

js
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches
      .match(event.request)
      .then((resp) => {
        return (
          resp ||
          fetch(event.request).then((response) => {
            let responseClone = response.clone();
            caches.open("v1").then((cache) => {
              cache.put(event.request, responseClone);
            });

            return response;
          })
        );
      })
      .catch(() => {
        return caches.match("./sw-test/gallery/myLittleVader.jpg");
      }),
  );
});

Здесь мы решили обрабатывать только картинки, потому что единственные запросы, которые могут не удастся — это загрузка новых картинок, так как все остальное было закешировано во время обработки события install, которое мы обсуждали ранее.

Обновление сервис-воркера

Если после того, как сервис-воркер был установлен, стала доступна его новая версия, то при обновлении или загрузке страницы она будет установлена в фоновом режиме, но не будет активирована. Она будет активирована, лишь когда не останется ни одной страницы, использующей старую версию сервис-воркера. Как только такие страницы перестанут загружаться, активируется новый сервис-воркер.

Примечание: Это можно обойти с помощью Clients.claim().

Обновить обработчик события install в новой версии сервис-воркера можно таким способом (обратите внимание на номер новой версии):

js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v2");
  await cache.addAll(resources);
};

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",

      // ...

      // подключение прочих ресурсов для новой версии...
    ]),
  );
});

Во время установки сервис-воркера предыдущая версия отвечает за обработку запросов. Новая версия устанавливается в фоновом режиме. Поскольку вызывается новый кеш v2, предыдущий кеш v1 не затрагивается.

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

Удаление старого кеша

Как можно увидеть в предыдущем примере, при обновлении сервис-воркера до новой версии, создаётся новый кеш в обработчике событий install. Пока есть открытые страницы, которые контролируются предыдущей версией воркера, необходимо сохранять оба кеша, так как предыдущей версии требуется своя версия кеша. Можно использовать событие activate для удаления данных из предыдущих кешей.

Промисы, переданные в waitUntil(), будут блокировать другие события до завершения, поэтому можно быть уверенным, что операция очистки будет завершена к тому времени, когда будет получено первое событие fetch для нового сервис-воркера.

js
const deleteCache = async (key) => {
  await caches.delete(key);
};

const deleteOldCaches = async () => {
  const cacheKeepList = ["v2"];
  const keyList = await caches.keys();
  const cachesToDelete = keyList.filter((key) => !cacheKeepList.includes(key));
  await Promise.all(cachesToDelete.map(deleteCache));
};

self.addEventListener("activate", (event) => {
  event.waitUntil(deleteOldCaches());
});

Инструменты разработчика

Смотрите также