Количество выходов в интернет с мобильных устройств ежегодно растёт на 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. Хочется понять, насколько тема интересна и полезна. Жду ваших комментариев и пожеланий.

Комментарии (20)


  1. p00h
    26.12.2017 07:28

    Несколько раз пытался разобраться с этими самыми SW, но все не хватало времени. А теперь все вопросы отпали, пойду прикручивать в паре мест.


    1. AndrewMikhailov Автор
      26.12.2017 07:30

      Рад, что смог помочь)


  1. lovespy
    26.12.2017 10:35

    Использую гугловский github.com/GoogleChrome/sw-toolbox и .networkFirst политику кэширования.

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


  1. devlev
    26.12.2017 12:16

    Не могу не добавить свои рассуждения по SW:

    1. Поддержка браузеров!


      1. AndrewMikhailov Автор
        26.12.2017 12:49

        1. Про поддержку браузеров.
        Как я знаю поддержка в таких браузерах как Safari или Edge находится в разработке.
        Поэтому данная технология довольно-таки перспективна на мой взгляд.
        Ровно как и часть крупных приложений уже начинают использовать их на проде (twitter, tinder, pinterest, smashingmagazine).

        2 и 3
        -5% -5% +25% — как сказал Артём Белов: “… сначала забирает, а потом добавляет с лихвой...”
        По поводу прокси, пушей и как настроить общение между воркером и веб-страницей ещё будет статья. Тут идея была расписать базовые стратегии для кэширования и как их использовать, по сути “на пальцах“.

        Касательно, выставить заголовки и т.п.
        Базовые средства оптимизации никто не отменяет)
        Кешировать надо все, что только возможно (с умом естественно), просто SW может помочь сделать приложение более отзывчивым и ускорить его отображение пользователю.

        По поводу оффлайна:
        А всё ли надо засовывать в оффлайн? На мой взгляд нужно лишь класть критичные файлы (css, js, html) и правильно обрабатывать ситуацию, когда нет доступа к сети, оставляя пользователя в приложении, а не заставляя его ждать минуту и в итоге показать Динозаврика.


        1. devlev
          26.12.2017 13:35

          Статья задумывалась начальной в серии статей о SW API. Хочется понять, насколько тема интересна и полезна. Жду ваших комментариев и пожеланий.

          В общем, продолжайте!


          1. AndrewMikhailov Автор
            26.12.2017 13:43

            Спасибо большое)))
            Как говориться, подписывайтесь, ставьте лайки и комментируйте)))


    1. kellas
      26.12.2017 12:31

      Почему-то про сервис-воркеры пишут только в контексте кэша и работы оффлайн.

      Использую для выполнения фоновых операций, +push уведомления юзерам о статусе этих операций и возможном прекращении их выполнения(если видно что закрыты все вкладки).
      Как результат например общая для всех вкладок область видимости/«глобальная асинхронность», то есть если юзер открыл сервис в нескольких вкладках он не может запустить занового идентичный процесс, т.к. этот процесс идет в едином сервис-воркере.


      1. AndrewMikhailov Автор
        26.12.2017 12:52

        Да, в курсе про такие операции)
        Это очень классная вещь)
        Я хотел начать с базовых вещей, а далее, в последующих статьях хочется погрузиться в те же пуши, фоновые операции и в другие интересные вещи)


    1. argonavtt
      26.12.2017 14:08

      Простите, я возможно отстал от жизни, но неужели SW стал по настоящему доступен на всех устройствах, помнится пол года назад с ним были явные проблемы на устройствах Apple.


      1. AndrewMikhailov Автор
        26.12.2017 14:09

        Про поддержку браузеров можете посмотреть в этом комменте
        habrahabr.ru/company/2gis/blog/345552/#comment_10586162

        О каких именно проблемах вы говорите? Поддержки сейчас нет в Safari — это правда, но как минимум SW в разработке для Safari и Edge.


    1. itlen
      26.12.2017 14:38

      Нужна статья о синхронизации и фоновом обновления кэша. Например когда используется cacheFirst и кэшируется вообще все. Статику понятно, можно версионированием кэшей контролировать, а вот с контентом пока только костыли использовал. Например пользователь из листинга ушел в карточку товара, там добавил его в корзину, возвращается на листинг, а ему отдает версия страницы из кэша в которой корзина пустая. Делал для этого historyArr[] в котором хранил все request.url и при определенном запросе на сервер обновлял contentCache чтобы контент всегда был актуальный, но так как немношк не разраб, чувствую что костыль не оч верный и есть нативная синхронизация для подобных вещей.


      1. AndrewMikhailov Автор
        26.12.2017 14:41

        как я понимаю из вашего контекста, данная задача мало связана с SW.
        Если мы говорим о SPA, то подобную задачу можно решить через тот же redux и управлением данными через store, то есть вы сами контролируете, какой листинг вы открыли и если есть данные в store, то запрос можно не делать, а брать данные прямиком из хранилища.


    1. itlen
      26.12.2017 15:06

      Нет, не SPA, обычное PWA которое прикручивается к обычному интернет-магазину с обычным php на бэкенде. Контент в SW делится на imgCache, staticCache и contentCache.

      Во время сёрфинга по сайту в contentCache пишется text/html и соответственно при cacheFirst отдается из кэша. Так вот именно его нужно обновлять при определенных действиях: добавили в корзину, удалил, добавил в избранное, к сравнению, лайк\дизлайк, оставил комментарий. Потому что он хранит контент до этих действий. Надеюсь так понятнее :)

      И вот такой костыль решает задачу, но чувствую что есть нативное решение.
      function updateCacheByName (cacheName,updateRequestsArray) {
      caches.delete(cacheName)
      .then(function(){
      console.log('удалили кэш '+cacheName+' чтобы обновить');
      caches.open(cacheName).then(cache => {
      return cache.addAll(updateRequestsArray).then(function(){
      console.log('история обновлена в кэше '+cacheName);
      }).catch( err => {console.log(err);});
      });
      }).catch( err => {console.log(err);});
      }


      1. AndrewMikhailov Автор
        26.12.2017 16:27

        Я ответил Вам в личку, чтобы не понижать контекст статьи и в целом ее обсуждения)


    1. questor
      26.12.2017 20:26

      Мне нравятся статьи, в которых последовательно наращивается уровень материала.


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


      В общем, я за продолжение.


    1. vlasenkofedor
      27.12.2017 01:51

      В-третьих, из соображений безопасности SW работают только по HTTPS

      Это не правда, есть возможность динамически создавать worker
      Динамический worker
          function getWorker() {
              var blob;
              window.URL = window.URL || window.webkitURL || window.mozURL || window.msURL;
              var mimeString = 'application/javascript';
              var code = "onmessage = function(e) { postMessage('My' + e.data); }";
              try {
                  blob = new Blob([code], {type: mimeString});
              } catch (e) {
                  var BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder ||
                      window.MozBlobBuilder || window.MSBlobBuilder;
                  var bb = new BlobBuilder();
                  bb.append(code);
                  blob = bb.getBlob(mimeString);
              }
              var url = window.URL.createObjectURL(blob);
              var worker = new Worker(url);
              URL.revokeObjectURL(url);
              return worker;
          }
      
          var worker = getWorker();
          worker.postMessage('Test');
          worker.onmessage = function (e) {
              console.log(e.data);
          };
      


      1. vlasenkofedor
        27.12.2017 02:07

        P. S. Позже увидел, что спутал serviceWorker с Worker


    1. noodles
      27.12.2017 01:57

      Недавно, один популярный web-сервис внедрил подход «offline first» (или как он там правильно называется). Так вот, однажды при использовании я не сразу заметил, что интернета нет, и продолжал пользоваться сервисом. При этом, несколько раз ловил дикий «ux confusing», если можно так выразится. И только потом заметил маленькую системную надпись от браузера (рядом с протоколом в адресной строке) — типа «сайт offline», или как-то так. Я был несколько расстроен, потому что понял что терял время и контент был невалидный, а взаимодействие фейковое. Ещё подумал про себя — «началось, начали тестить продвинутое кеширование и сервис-воркеры». Так это я разобрался, а что говорить про людей далёких от web-разработки.

      Поэтому:

      Существует проблема, когда браузер по умолчанию выдаёт вам сообщение о том, что вы офлайн

      как по мне, так это не проблема, а вполне понятное, чёткое и однозначное информирование о том что интернета пока нет, займись чем-то другим!
      Моё мнение, что для сервисов, где есть плотное взаимодействие с пользователем, или контентных сервисов — подход «offline first» неудачный. Он годится лишь для чисто клиентских самодостаточных приложений (типа как тот гудящий горн на google developers, который приводят везде в пример)

      p.s. хотя подозреваю, я тут напутал и намешал с pwa..))


      1. AndrewMikhailov Автор
        28.12.2017 08:01

        Тут на самом деле понамешано малость.
        Да, PWA это про SW, но SW — это не про PWA.

        > как по мне, так это не проблема, а вполне понятное, чёткое и однозначное информирование о том что интернета пока нет, займись чем-то другим!

        Как я вижу в плане дизайна приложения был допущена ошибка, что пользователь явно не показал, что вы в Offline. По мне это решается довольно просто:

        window.addEventListener('offline', () => showOfflineBar());
        

        Это уже проблема приложения, а не SW. Как я выше писал в своей статье, наша задача не просто не показывать «динозаврика», а показать своего «динозаврика» в стиле нашего приложения и была «наша» кнопка «обновить».