В этой статье я хотел бы поговорить о Service Workers (SW). SW позволяют нам сделать наше приложение готовым к работе в автономном режиме, чтобы оно работало, даже если у нас нет подключения к Интернету. Они также позволяют нам использовать множество других расширенных функций, таких как push-уведомления или фоновая синхронизация. SW продолжает работать даже после закрытия браузера, то есть Service Workers продолжают работать. Это фоновый процесс. Итак, давайте зарегистрируем нашего первого Service Worker’a.

(В этой статье я реализую функциональность, связанную с SW, на простом JS, поскольку код написан на простом JS, мы можем интегрировать в любые JS-фреймворки, такие как Angular, React или Vue)

В качестве первого шага добавим файл sw.js в корневую папку проекта. В app.js мы должны проверить, доступен ли SW в навигаторе, то есть поддерживаются ли SW данным браузером. Теперь, когда мы знаем, что SW доступны, мы можем выполнить метод navigator.serviceWorker.register (), указывая путь к файлу, в котором находится наш SW, чтобы его зарегистрировать. Этот метод фактически возвращает Promise. Итак, чтобы получить информацию, как только это будет сделано, мы можем присоединиться к нему.

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('/sw.js')
    .then(event => {
      console.log('Service worker registered', event);
    });
}

Поскольку мы зарегистрировали нашего первого SW, давайте добавим наш первый прослушиватель событий. Как я уже сказал, SW работают в фоновом режиме. Но я не упомянул одну вещь, что все они связаны с обработкой событий. Чтобы прикрепить слушателей событий к SW, мы, прежде всего, должны обратиться к нему с помощью ключевого слова self, что в основном означает «предоставь мне доступ к SW», а затем мы можем выполнить метод addEventListener (). SW надают доступ к специальному набору событий, например, к событию установки, которое запускается, когда браузер устанавливает Service Worker’a. Здесь мы выполняем функцию и получаем объект события, который автоматически передается в функцию браузером, и этот объект предоставляет нам информацию о событии установки. Как мы видим, наш Service Worker успешно установлен.

self.addEventListener('install', event => {
  console.log('Installing [Service Worker]', event);
});

Теперь мы можем приступить к реализации статического или предварительного кэширования. Фаза установки Service Worker’a - отличное место для кеширования некоторых ресурсов, которые меняются не очень часто, например оболочки приложения или базового стиля. В прослушивателе событий установки мы пишем caches, чтобы получить доступ к API кеша, а затем метод open (), передавая имя нашего кеша. Таким образом, мы открываем там новый кеш. Но сначала нам нужно обернуть это выражение выражением event.waitUntil (). Оно просто ждет, пока не завершится вся работа с кешированием. Это не позволит завершить установку. В блоке then мы получаем доступ к этому кешу и теперь можем добавлять в него контент. При написании cache.addAll () мы добавляем файлы, которые представляют оболочку нашего приложения.

self.addEventListener('install', event => {
  console.log('Installing [Service Worker]', event);

  event.waitUntil(
    caches.open('static')
      .then(cache => {
        console.log('[Service Worker] Precaching App Shell');
        cache.addAll([
          '/',
          '/index.html',
          '/favicon.ico',
          '/src/js/app.js',
          '/src/js/chart.js',
          '/src/js/materialize.js',
          '/src/js/materialize.min.js',
          '/src/css/materialize.css',
          '/src/css/materialize.min.css',
          '/src/css/style.css',
          'https://fonts.googleapis.com/icon?family=Material+Icons',
          'https://code.jquery.com/jquery-2.1.1.min.js',
          'https://cdn.jsdelivr.net/npm/chart.js@2.8.0'
        ]);
      }));
});

Теперь посмотрим, действительно ли эти файлы загружены в кеш-память.

Следующим шагом мы должны получить эти файлы из кеша, чтобы наше приложение могло работать в автономном режиме. Посмотрим, как это сделать. Еще одно очень важное событие, которое мы также можем прослушать, - это событие выборки. Fetch будет запускаться всякий раз, когда наше веб-приложение что-то извлекает, например файлы css и js или даже запросы xhr. Итак, в прослушивателе событий fetch Service Worker’a давайте убедимся, что мы действительно получаем данные из нашего кеша. Сначала я добавлю это решение, а затем объясню, как оно работает.

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) {
          return response;
        } else {
          return fetch(event.request);
        }
      })
    );
});

Выражение event.respondWith () позволяет нам перезаписывать данные, которые отправляются обратно. Вы можете думать о Service Worker’е как о сетевом прокси, по крайней мере, если мы используем здесь событие fetch. Таким образом, каждый исходящий запрос на выборку проходит через Service Worker, как и каждый ответ. То есть, если мы ничего не делаем, ответ просто не передается. Выражение cashes.match () позволяет нам проверить, кэширован ли уже данный запрос. Если это так, он вернет кешированный ответ. Мы не делаем сетевой запрос, мы перехватываем запрос и не выдаем новый, вместо этого мы просто смотрим на то, что мы хотели запросить, и видим, есть ли оно в кеше и если есть, мы возвращаем его обратно. С другой стороны, если мы не находим его в кеше, мы хотим вернуть запрос на выборку там, где мы обращаемся или где мы просто продолжаем исходный запрос, поэтому вертаем fetch (event.request). После всех этих изменений мы наконец можем использовать наше веб-приложение в автономном режиме. 

Как видите, наше веб-приложение содержит диаграмму с некоторыми статическими данными, и при нажатии кнопки «ПОЛУЧИТЬ ДАННЫЕ» ничего не происходит. Теперь я хочу сделать так, чтобы нажав эту кнопку, мы получили некоторые статистические данные, отобразили их на диаграмме и сохранили эти данные в кеше. Таким образом, мы реализуем динамическое кеширование. Итак, приступим. Допустим, у нас есть эндпоинт, который возвращает статистические данные о том, сколько пользователей посетили наш сайт. Итак, теперь мы должны взять эти данные и отобразить их на графике.


    Object.keys(pureData).forEach(key => tmp[sorter[key.toLowerCase()]] = { key, value: pureData[key] });

    tmp.forEach(obj => orderedData[obj.key] = obj.value);

    const ctx = document.getElementById('myChart').getContext('2d');

    new Chart(ctx, {
      type: 'line',
      data: {
          labels: Object.entries(orderedData).map(([key, _]) => key),
          datasets: [{
              label: 'Users',
              backgroundColor: '#26a69a',
              borderColor: '#26a69a',
              fill: false,
              data: Object.entries(orderedData).map(([_, value]) => value),
          }]
      }
    });
  });
};

Как и раньше, я добавлю решение, а затем объясню, как оно работает.

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) {
          return response;
        } else {
          return fetch(event.request)
            .then(res => {
              return caches.open('dynamic')
                .then(cache => {
                  cache.put(event.request.url, res.clone());
                  return res;
                })
            });
        }
      })
    );
});

Динамическое кеширование просто означает, что у нас в любом случае есть событие выборки, и мы хотим сохранить ответ, который возвращается в нашем кеше. Как и раньше, мы пишем caches, чтобы получить доступ к API кеша и методу open (), передавая имя нашего кеша. Выражение cache.put () просто хранит данные, которые у нас есть. Первый аргумент, который вы передаете, - это URL-адрес запроса события, идентификатор. Второй аргумент - это ответ. Итак, мы храним точный клон, который нам нужен, он содержит все данные ответа, но мы возвращаем исходный ответ. Вот и все. Сначала мы берем статистические данные с нашего сервера и сохраняем их в кеше. Второй раз мы возьмем эти данные из кеша. Это решение отлично работает не только с запросами xhr. Например, таким образом мы можем динамически кэшировать файлы css или даже изображения.

В дополнение хочу сказать несколько слов о фоновой синхронизации. Фоновая синхронизация - это отправка данных на сервер, когда у нас нет подключения к Интернету. Так как же это работает за кулисами? Мы можем использовать SW для регистрации задачи синхронизации. Конечно, регистрация задачи - это еще не все, что нам нужно сделать, нам также необходимо сохранить данные, которые мы хотим отправить с запросом, в indexedDB. Поэтому, если у нас не было подключения и оно было восстановлено, SW немедленно выполнит эту задачу. Так называемое событие синхронизации будет выполнено в SW, и мы сможем прослушать это событие. Приятно то, что это будет работать, даже если мы закроем вкладку или даже в браузерах на мобильных телефонах. Теперь я хочу зарегистрировать первую задачу синхронизации, и для этого я прежде всего проверю, есть ли у нас доступ к Service Worker’y в данном браузере. Однако мы также должны проверить, доступен ли менеджер синхронизации в окне. Менеджер синхронизации - это, по сути, API, через который мы используем функции фоновой синхронизации. Затем я обращусь к своему Service Worker’y, и там я могу вызвать свойство ready, чтобы убедиться, что оно настроено. Итак, теперь мы можем работать с ним. Чтобы зарегистрировать новую задачу синхронизации, мы должны получить доступ к свойству синхронизации (это дает нам доступ к диспетчеру синхронизации), и там мы можем вызвать метод регистрации. Он принимает только один аргумент, и это идентификатор, который идентифицирует данную задачу синхронизации. Итак, я назову его «запрос синхронизации». Позже мы будем использовать это имя в Service Worker’е, чтобы отреагировать на восстановленное соединение и проверить, какие нерешенные задачи у нас есть, а затем мы сможем использовать тег, чтобы узнать, что нам нужно делать с этой задачей.

if ('serviceWorker' in navigator && 'SyncManager' in window) {
      navigator.serviceWorker.ready
        .then(sw => {
          sw.sync.register('sync-request')
        });
    }

Теперь я хочу реализовать, чтобы после нажатия кнопки «POST DATA» данные, которые мы хотим отправить, сохранялись в indexedDB и зарегистрировать новую задачу синхронизации. Для этого мы прежде всего должны добавить несколько дополнительных файлов в наш проект, чтобы легко работать с indexedDB. Затем давайте создадим данные, которые мы хотим сохранить. Это будет простой объект с двумя свойствами. Первое свойство - это идентификатор. Второе свойство называется «sunday», значение которого равно 10 (для полноты картины :)). Для хранения этих данных мы используем вспомогательную функцию writeData из utility.js, которая принимает два аргумента. Первый аргумент - это имя базы данных, в которой будут храниться данные, а второй - сами наши данные. После успешного выполнения регистрируем новую задачу синхронизации.

const syncButton = document.getElementById('sync-button');

syncButton.addEventListener('click', _ => {
    if ('serviceWorker' in navigator && 'SyncManager' in window) {
      navigator.serviceWorker.ready
        .then(sw => {
          const data = {
            id: new Date().getTime(),
            sunday: 10
          };

          writeData('sync-requests', data)
            .then(_ => {
              sw.sync.register('sync-request')
            });
        });
    }
});

И, наконец, мы подошли к самому интересному - прослушиванию события синхронизации и реагирования на восстановление соединения. Как и раньше, я добавлю решение и объясню, как оно работает.

self.addEventListener('sync', event => {
  console.log('[Service Worker] Syncing');

  if (event.tag === 'sync-request') {
    event.waitUntil(
      readAllData('sync-requests')
        .then(async data => {
          const requests = [];

          for (const d of data) {
            requests.push(fetch('https://simple-pwa-8a005.firebaseio.com/data.json', {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json'
              },
              body: JSON.stringify({
                sunday: d.sunday
              })
            }));
          }

          const results = await Promise.all(requests);

          results.map((response, index) => {
            if (response.ok) {
              deleteItemFromData('sync-requests', data[index].id);
            }
          })
        })
    );
  }
});

Сначала нам нужно проверить тег события. Затем я использую event.waitUntil (), как и раньше, это просто позволяет мне убедиться, что событие не завершилось преждевременно. Затем мы получаем данные, которые хранятся в indexedDB (используя вспомогательную функцию из utility.js), перебираем их, отправляем post запрос для каждого из сохраняемых фрагментов данных, а затем удаляем их из indexedDB, если мы успешно отправили их на сервер. Вот и все. Давайте теперь попробуем это. Чтобы проверить эту функциональность, мы должны перейти в автономный режим в нашем браузере, нажать кнопку «POST DATA» и затем снова выйти в онлайн.

После нажатия кнопки «POST DATA», когда мы не в сети, ничего не происходит, но когда подключение восстанавливается, мы видим, что синхронизация была выполнена.

И чтобы подтвердить, что данные действительно были отправлены на сервер, нам сначала нужно удалить наш запрос на получение из динамического кеша и нажать кнопку «ПОЛУЧИТЬ ДАННЫЕ». 

На этом пока все. Увидимся позже. Мой код доступен на github: https://github.com/Draakan/simplePWA