Как заставить ваши веб-приложения работать в автономном режиме

Автор оригинала: Bowei Han
  • Перевод
Сила JavaScript и браузерного API

Мир становится все более взаимосвязанным — число людей, имеющих доступ к Интернету, выросло до 4,5 миллиардов.

image

Но в этих данных не отражено количество людей, у которых медленное или неисправное интернет соединение. Даже в Соединенных Штатах 4,9 миллиона домов не могут получить проводной доступ к интернету скорость которого будет более 3 мегабит в секунду.

Остальной мир — те, кто имеет надежный доступ к Интернету — все еще подвержен потере соединения. Некоторые факторы, которые могут повлиять на качество сетевого подключения, включают в себя:

  • Плохое покрытие от провайдера.
  • Экстремальные погодные условия.
  • Перебои питания.
  • Пользователи, попадающие в «мертвые зоны», такие как здания, которые блокируют их сетевые подключения.
  • Путешествие на поезде и проезд туннелей.
  • Соединения, которые управляются третьей стороной и ограничены во времени.
  • Культурные практики, которые требуют ограниченного или отсутствия доступа в Интернет в определенное время или дни.

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

EDISON Software - web-development
Статья переведена при поддержке компании EDISON Software, которая выполняет «на отлично» заказы из Южного Китая, а также разрабатывает веб-приложения и сайты.
Недавно у меня была возможность добавить автономность к существующему приложению, используя service workers, cache storage и IndexedDB. Техническая работа, необходимая для того, чтобы приложение работало в автономном режиме, сводилась к четырем отдельным задачам, о которых я расскажу в этом посте.

Service Workers


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

При ошибке загрузки веб-приложения, мы должны где-то взять ресурсы для браузера(HTML/CSS/JavaScript). Откуда берутся эти ресурсы, если не из сетевого запроса? Как насчет кеша. Большинство людей согласятся с тем, что лучше предоставлять потенциально устаревший пользовательский интерфейс, чем пустую страницу.

Браузер постоянно делает запросы к данным. Служба кэширования данных в качестве запасного варианта все еще требует, чтобы мы каким-то образом перехватывали запросы браузера и писали правила кэширования. Здесь service workers вступают в игру — думайте о них как о посреднике.

image

Service worker — это просто файл JavaScript, в котором мы можем подписаться на события и написать свои собственные правила для кэширования и обработки сетевых сбоев.
Давайте начнем.

Обратите внимание: наше демо приложение

На протяжении всего этого поста мы будем добавлять автономные функции в демо приложение. Демо-приложение представляет собой простую страницу взятия/сдачи книг в библиотеке. Прогресс будет представлен в виде серии GIF-файлов, и использования офлайн симуляции Chrome DevTools.

Вот начальное состояние:

image

Задача 1 — Кэширование статических ресурсов


Статические ресурсы — это ресурсы, которые меняются не часто. HTML, CSS, JavaScript и изображения могут попадать в эту категорию. Браузер пытается загрузить статические ресурсы с помощью запросов, которые могут быть перехвачены service worker’ом.

Начнем с регистрации нашего service worker’a.

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js');
  });
}

Service worker'ы являются web worker'ами под капотом и поэтому должны быть импортированы из отдельного файла JavaScript. Регистрация происходит с помощью метода register после загрузки сайта.
Теперь, когда у нас загружен service worker — давайте закешируем наши статические ресурсы.

var CACHE_NAME = 'my-offline-cache';
var urlsToCache = [
  '/',
  '/static/css/main.c9699bb9.css',
  '/static/js/main.99348925.js'
];

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        return cache.addAll(urlsToCache);
      })
  );
});

Поскольку мы контролируем URL-адреса статических ресурсов, мы можем их кэшировать сразу после инициализации service worker’a используя Cache Storage.

image

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

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

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

Демо № 1


image

Наше демо-приложение теперь может обслуживать статические ресурсы в автономном режиме! Но где наши данные?

Задача 2 — Кэширование динамических ресурсов


Одностраничные приложения (SPA) обычно запрашивают данные постепенно после начальной загрузки страницы, и наше демо приложение не является исключением — список книг не загружается сразу. Эти данные обычно поступают из запросов XHR, которые возвращают ответы, которые часто меняются, чтобы предоставить новое состояние приложения — таким образом, они являются динамическими.

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

Посмотрим на наш обработчик fetch:

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

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

self.addEventListener('fetch', function(event) {
 event.respondWith(
   fetch(event.request)
     .then(function(response) {
       caches.open(CACHE_NAME).then(function(cache) {
         cache.put(event.request, response);
       });
     })
     .catch(function() {
       caches.match(event.request).then(function(response) {
         return response;
       }
     );
 );
});

Наш Cache Storage в настоящее время имеет несколько записей.

image

Демо № 2


image

Наше демо теперь выглядит одинаково при начальной загрузке, независимо от нашего статуса сети!

Отлично. Давайте теперь попробуем использовать наше приложение.

image

К сожалению — сообщения об ошибках везде. Похоже, все наши взаимодействия с интерфейсом не работают. Я не могу выбрать или сдать книгу! Что нужно исправить?

Задача 3 — Построить оптимистичный пользовательский интерфейс


На данный момент проблема с нашим приложением заключается в том, что наша логика сбора данных все еще сильно зависит от сетевых ответов. Действие check-in или check-out отправляет запрос на сервер и ожидает успешного ответа. Это отлично для согласованности данных, но плохо для нашего автономного опыта.

Чтобы эти взаимодействия работали в автономном режиме, нам нужно сделать наше приложение более оптимистичным. Оптимистичные взаимодействия не требуют ответа от сервера и охотно отображают обновленное представление данных. Обычная оптимистичная операция в большинстве веб-приложений это delete — почему бы не дать пользователю мгновенную обратную связь, если у нас уже есть вся необходимая информация?

Отключение нашего приложения от сети с использованием оптимистичного подхода является относительно простой в реализации.

case CHECK_OUT_SUCCESS:
case CHECK_OUT_FAILURE:
  list = [...state.list];
  list.push(action.payload);
  return {
    ...state,
    list,
  };
case CHECK_IN_SUCCESS:
case CHECK_IN_FAILURE;
  list = [...state.list];
  for (let i = 0; i < list.length; i++) {
    if (list[i].id === action.payload.id) {
      list.splice(i, 1, action.payload);
    }
  }
  return {
    ...state,
    list,
  };

Ключ — обрабатывать действия пользователя одинаково — независимо от того, успешен ли сетевой запрос или нет. Приведенный выше фрагмент кода взят из redux редюсера нашего приложения, SUCCESS и FAILURE запускается в зависимости от доступности сети. Независимо от того, как выполнен сетевой запрос, мы собираемся обновить наш список книг.

Демо № 3


image

Взаимодействие с пользователем теперь происходит онлайн (не буквально). Кнопки «check-in» и «check-out» обновляют интерфейс соответствующим образом, хотя по красным сообщениям консоли видно, что сетевые запросы не выполняются.

Хорошо! Есть только одна небольшая проблема с оптимистичным рендерингом в автономном режиме…

Разве мы не теряем наши изменения!?

image

Задача 4 — Собирать действия пользователя в очередь для синхронизации


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

  • Асинхронные неблокирующие операции
  • Значительно более высокие лимиты хранения
  • Управление транзакциями

Посмотрите на наш старый код редюсера:

case CHECK_OUT_SUCCESS:
case CHECK_OUT_FAILURE:
  list = [...state.list];
  list.push(action.payload);
  return {
    ...state,
    list,
  };
case CHECK_IN_SUCCESS:
case CHECK_IN_FAILURE;
  list = [...state.list];
  for (let i = 0; i < list.length; i++) {
    if (list[i].id === action.payload.id) {
      list.splice(i, 1, action.payload);
    }
  }
  return {
    ...state,
    list,
  };

Давайте изменим его, чтобы хранить события check-in и check-out в IndexedDB при событии FAILURE.

case CHECK_OUT_FAILURE:
  list = [...state.list];
  list.push(action.payload);
  addToDB(action); // QUEUE IT UP
  return {
    ...state,
    list,
  };
case CHECK_IN_FAILURE;
  list = [...state.list];
  for (let i = 0; i < list.length; i++) {
    if (list[i].id === action.payload.id) {
      list.splice(i, 1, action.payload);
      addToDB(action); // QUEUE IT UP
    }
  }
  return {
    ...state,
    list,
  };

Вот реализация создания IndexedDB вместе с хелпером addToDB.

let db = indexedDB.open('actions', 1);
db.onupgradeneeded = function(event) {
  let db = event.target.result;
  db.createObjectStore('requests', { autoIncrement: true });
};

const addToDB = action => {
  var db = indexedDB.open('actions', 1);
  db.onsuccess = function(event) {
    var db = event.target.result;
    var objStore = db
      .transaction(['requests'], 'readwrite')
      .objectStore('requests');
    objStore.add(action);
  };
};

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

window.addEventListener('online', () => {
  const db = indexedDB.open('actions', 1);
  db.onsuccess = function(event) {
    let db = event.target.result;
    let objStore = db
      .transaction(['requests'], 'readwrite')
      .objectStore('requests');
    objStore.getAll().onsuccess = function(event) {
      let requests = event.target.result;
      for (let request of requests) {
        send(request); // sync with the server
      }
    };
  };
});

На этом этапе мы можем очистить очередь от всех запросов, которые мы успешно отправили на сервер.

Демо № 4


Финальное демо выглядит немного сложнее. Справа в темном терминальном окне регистрируется вся активность API. Демо предполагает выход в автономный режим, выбор нескольких книг и возврат в онлайн.

image

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

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

Это всё


Выйдите и сделайте ваши веб-приложения способным работать в автономном режиме! Этот пост демонстрирует некоторые из многих вещей, которые вы можете сделать, чтобы добавить автономные возможности в свои приложения, и определенно не является исчерпывающим.
Чтобы узнать больше, ознакомьтесь с Google Web Fundamentals. Чтобы увидеть другую офлайн-реализацию, ознакомьтесь с этим докладом.
Edison
351,86
Изобретаем успех: софт и стартапы
Поделиться публикацией

Похожие публикации

Комментарии 25

    +2

    Всё никак не освою Service Workers. Всё по старинке пользую cache manifest. Он не такой гибкий зато не требует JavaScript. Вижу преимущество Service Workers в кешировании по запросу. Возможно их можно совместить.


    Будет ли Service Workers работать если выключить для сайта JavaScript через настройки браузера или через NoScript? Предполагаю что нет.

      +2

      У Service Workers есть один фатальный недостаток: нормально работает только по HTTPS. С одной стороны безопасность это прекрасно, но когда ты не контролируешь эту опцию это порой приводит к боли и страданиям.
      Пример кейса, когда SW при использовании приносит боль:


      Маленький сервис для внутренних нужд компании, с большой frontend-логикой. Он неплохо защищён во внутренней сети, хотя в этом и нужды нет. Но SW не регистрируется, если фронт не через https, поэтому кеширование не работает, установить как PWA — не работает. Для локальных машин можно было бы установить свой корневой сертификат, но как быть с Android/iOS?
        +2
        В чем проблема внутренний сервис крутить на service.corp.company.com, рядом с другими сервисами на *.corp.company.com, где у *.corp.company.com есть настоящий сертификат и резолвится-обслуживается оно при этом только в интранете? (ну начиная с самого простого варианта когда эти домены обслуживаются только внутренними нейм-серверами и резолвятся во внутренние IP и заканчивая разными другими практиками).
          +1

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

            +2
            letsencrypt в автоматическом режиме с dns-челленджем. https нужен всему, web-workers, это не единственное API, которое требует https и будет хуже.
              +1

              Проблема больше наверное даже не в самом https, а в том, как делать те или иные сертификаты доверенными или получить их от доверенного.
              А прикручивать certbot на каждый сервис то ещё удовольствие. У нас Кубик внутренний и dns-челлендж там не самый простой процесс (во всяком случае пока не нашёл ничего внятного, что завелось бы).

                +2
                Там же, кажется, можно wildcard сертификат оформить? Wildcard на *.corp.company.com — это ведь всего один пайплайн.
                  +1

                  Итого:


                  1. Всё строго в одном домене.
                  2. Каждый новый сервис будет требовать танцы с бубном и тревогу в сердце подсервиса для обновления сертификата и мониторинга ("дьявольская ромашка" или любит/не любит — отработает/сломается).
                  3. Нужду в AWS Route53, даже если есть своё "облако".

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

                    0
                    Но постойте.
                    1. Не «все в одном домене», а «один домен — один пайплайн для обновления сертификата». Если пайплайн унифицировать и запустить в какой-нибудь ci — то проблем никаких, хоть тысяча доменов.
                    2. Зачем? Я что-то не понял. Заверните все ваши сервисы в один условный load balancer который владеет сертификатом и внутри уже роутит траффик на куда угодно.
                    3. Ну вы же где-то держите ns'ы своего company.com все равно? Добавьте туда один wildcard IN вида *.corp.company.com, который будет направлять на тот самый «лоад балансер/роутер» с одним сертификатом, либо даже проще, если есть нужда изолироваться от внешнего мира — на внутреннем dns сделайте эту запись безо всяких Route53.

                    Итого — один автоматизированный пайплайн для обновления wildcard сертификата (даже если там много доменов, надо только автоматизировать правильно), один lb (ну может горизонтально заскейленный), который этим сертификатом/сертификатами владеет + одна wildcard-запись в публичном или приватном NS.

                    И все для того чтобы все внутренние сервисы унифицировано работали по честному https потому что почему бы и нет.
                      +2

                      Зачем мне менять всю инфраструктуру ради HTTPS, который мне не нужен внутри сети?
                      Зачем мне lb, если сервисы могут работать и без него?
                      Вы понимаете, что значит децентрализированная модульная инфраструктура? Мне на каждый сервис лезть в балансировщик? И только затем, чтобы использовать SW?
                      Сейчас куча независимых сервисов работают и легко разворачиваются и убиваются. Умрёт какой-то сервис — другие работают. Вы же предлагаете кучу сервисов засунуть за один-N балансировщиков, которые нафиг не нужны, кроме как принудительно загонять трафик в https? Это же ректальное удаление гланд автогеном костыль!

                        0
                        Я ж вас не заставляю. Не хотите чтобы по https все работало — не надо, дело хозяйское. Мой поинт в том что это недорого и несложно (за вычетом того, что кругом может быть куча гвоздями забитых ссылок на эти внутренние ресурсы, которые в автоматическом режиме трудно обновить.

                        Ну и вообще — что у вас за мания везде руками лазить и что-то добавлять при каждом обновлении? Балансировщик (который на самом деле скорее роутер/реверс-прокси, а не балансировщик, бо нагрузки-то нет) тоже можно настроить так, чтобы он роутил в автоматическом режиме от запроса (где запросом является собственно поддомен), нужна только какая-то конвенция.

                        Оно же у сейчас как-то работает? Какие-то имена сервисам присваиваются так, чтобы они становились доступны внутри сети. Значит есть какой-то механизм, с которым можно дружиться.
            +2

            Наверное потому что помимо того что нужно делать https скорее всего нужно еще и настраивать авторизацию и регистрацию в том или ином виде. Если внутри сети можно политиками разрулить какой доступ к отчетам есть у какого отдела то вот в случае публичного сервиса эти отчеты торчат наружу что в некоторых случаях — вариант не очень собобенно с точки зрения безопасности.


            Сотрудники начнут смотреть эти отчеты из дома, с того же компа где из ребенок установил крякнутый launcher на minecraft с трояном. И вот отчеты уже доступны злоумышленнику. После чего часть из этих отчетов с личными данные клиентов (ФИО, Адреса, Номера банковских карт) сливаются и продаются по сети...

              +1
              Нет, это не так. Ваш домен с https может быть настроен так, что резолвится будет только на внутренние адреса компании, ни под каким соусом недоступные снаружи. Более того, он может быть настроен так, что будет резолвится еще и только через внутренние нейм-сервера.
                +2

                Если используется свои CA то и такой вариант можно провернуть, но тогда нужно через доменые политики их накатывать на каждый комп (windows) или создать скрипты их установки для *nix.


                Что касается доверенных сертификатов то на сколько я знаю let's encrypt и прочие вендоры не будут вам их оформлять например на ip 10.x.x.x или на домен www.mybestcompany.local возможно есть решения для enterprice которые сие могут но и поцена они собственно будет enterprice

                  +1
                  На let's encrypt есть ограничение о том, что домен должен быть видим и доступен. Но если в вашем случае это домен mycompany.com и у вашей компании на этом домене публичный сайт — никаких проблем. Через dns-challenge получаете wildcard-сертификат на *.corp.mycompany.com, и на какие ip и на каких NS-ах вы эти домены будете адресовать совершенно не имеет значения.

                  Т.е. mybestcompany.local конечно не выйдет так оформить, а вот *.local.mybestcompany.com — вполне. И резолвить эти адреса на внутренние IP потом, при желании — только с внутренних NS-ов (т.е. они снаружи вообще резолвится не будут). При этом сертификат будет «честный» — не надо будет пользователям вручную ничего накатывать.
                    +1

                    А на someting.lan нельзя никак. А вот многие таким паттерном пользуются.

                      0
                      ну я это и написал. Никто вас под дулом автомата (пока) не ведет на эшафот избавления от https. Однако мой поинт в том, что это в среднем не так дорого и не так сложно и очевидные профиты таки присутствуют (ну типа защищенный протокол везде по умолчанию, это ж хорошо, даже и внутри сети — все равно хорошо). Случаи всякие бывают.
                        +1
                        Вообще медленно пытаются вести на эшафот избавления от http без s.

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

                        Это… печально…
                      +1

                      Спасибо, до этого не сталкивался с такой задачей. Но решение отличное! буду знать на будущее!

              +2
              letsencrypt, чего тут думать.
              dns-плагины будут работать даже глубоко из-за ната, какой-нибудь *.subdomain.company.com нужно будет только на cloudflare делегировать (хотя можно и лапками на своих NS-ах записи создавать, но тогда с автопродлением проблемы).
              0

              В тему подскажу библиотеку от гугла: workbox. В простейшем случае ставится одной строчкой в вебпаке и одной в init.js, огромное количество возможностей. Из недостатков — не очень нравится документация, иногда сложно понять как заставить ее делать что тебе нужно

                0
                А чем кеширование файлов в вебворкере принципиально отличается от кеширования с помощью заголовков?
                  0

                  Думаю если нажать "обновить" при недоступном интернете то не будет сообщения "Попытка соединения не удалась" вместо страницы. Оно конечно лечится кнопкой "перейти" но её уже давно убрали из интерфейса.

                  +1

                  Очевидно — описанная в статье схема работы подойдет, если приложение простое, отсутствие коннекта кратковременное, не подразумевается разбиение на чанки со своей бизнес-логикой и если CI настроен на релизную схему, например раз в неделю или на схожий период.
                  В другом случае: то, что пользователь "накликал" может быть завязано на права доступа и конкретные ответы сервера, так что пайплайн событий будет сломан; редиректы и открытие новых страниц при разбиении на чанки не будет работать — если не загружать их всех к кэш, а тогда их смысл теряется наполовину; в подавляющем большинстве случаев "оптимистичное обновление" будет обманывать юзера, и он получит негатив, когда "делал-делал и вдруг все сломалось", т.к. возникнет реальный коннект; если приложение имеет несколько релизов в день например по Kanban CI, то количество поломанных действий возрастет, ошибки в логгер будут сыпаться тоннами, а причина одна — полный рассинхрон реальности и оффлайнового кэша, и в этой каше будет сложно разобраться.
                  При длительном дисконнекте данные будут приходить старые, например отсутствовать ответы техподдержки или не обновляться статус услуги, что повысит нагрузку на техсап, которые будут разводить руками — опять что-то с сайтом нахимичили разрабы. При перезагрузке страницы нужно будет восстанавливать "виртуальный" стейт, который разойдется с серверным, если используется server side rendering, что приведет к непредсказуемым последствиям.
                  Вывод: если чуть сложней todo листа — лучше выбирать стратегию показа нотификации "у вас отвалился инет" и проверять раз в секунду, не появился ли он, сохраняя по возможности стейт приложения, либо по лайтсу пытаясь его частично восстановить при долгом отсутствии коннекта.

                    +1
                    Большинство людей согласятся с тем, что лучше предоставлять потенциально устаревший пользовательский интерфейс, чем пустую страницу.

                    Не соглашусь.
                    Может лучше честно показать, что нет интернета и не тратить время пользователя, и не обманывать его ожидания «оптимистическими» ui. Взамен можно получить большой негатив, особенно если в оффлайн режиме дёргаются важные бизнес процессы, а на самом деле это не так. Сам на таком обжигался — очень не понравилось.

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

                    Самое читаемое