Обновление вашего PWA в продакшене

Слышали шутку о том, что если установил ServiceWorker - пора менять домен? Сейчас я расскажу, в чём её смысл и что делать, если вы всё-таки решили, что вам необходим PWA.

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

С вашего позволения, я не буду останавливаться на описании Service Worker (далее SW) и том, как он работает. На Хабре уже есть хорошая статья об этом. Даже не важно, какой SW конкретно у вас. Может, вы используете create-react-app, а значит за SW у вас отвечает библиотека Workbox. Возможно, вы реализовывали SW сами, с какой-то мудрённой стратегией кэширования. Стек на самом деле не важен. В той же документации CRA говорится, что всё, что вам нужно - это поменять одну строчку и получить все прелести app-like поведения. Вы написали .register() и ожидаете результат. И вы его получите.

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

— Обновите, пожалуйста, страницу. Как не помогает? А если CTRL+R ?

Итак, что же делать, когда судорожное обновление страницы не помогает и клиент всё ещё видит издевательски оранжевую кнопку?

Важно помнить, что SW пытается вести себя как десктопное приложение.

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

Схожим образом действует и браузер при обновлении SW.

Всего у SW три статуса: installing, waiting и active. Active - это ваш текущий, работающий SW. Стадии installing и waiting SW проходит на пути к active. На стадии installing SW нужно время, чтобы установиться. На стадии waiting ему нужна причина, чтобы заменить текущий SW (обычно это закрытие всех вкладок приложения). Вот в этом поведении и весь подвох.

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

Дело в том, что браузер начинает загружать обновлённую страницу до того, как старая "умрёт". И когда вы перезагружаете страницу, для SW существует аж две вкладки: старая, обречённая на смерть, и новая, которая ещё запускается. Пока не будут закрыты все, SW не обновится. Это нужно для того, чтобы вы не получали разные версии приложения во вкладках браузера.

Я намеренно пропускаю глубокий разбор механизма обновления SW с его озвученными выше статусами installing, waiting и active. Больше об этом написано тут - рекомендую ознакомиться. Мы уже понимаем механизм и вообще мы здесь, чтобы решить проблему.

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

Вариант №1: Заставить SW обновляться сразу

Самый простой (и опасный) способ - это просто пропустить ожидание в установке SW. В скоупе вашего SW есть прекрасная функция skipWaiting(), которая сделает это для вас. При её вызове новый SW после своей установки сразу убивает старый. Вам лишь надо дождаться "перезапуска" приложения.
Но будьте осторожны: данный подход несёт опасность, если у вашего пользователя открыты другие вкладки с приложением. Вам может показаться, что слепо вызывать skipWaiting() более чем достаточно, но это приводит к багам на вашем продакшене, которые потом сложно понять и воспроизвести.

Вариант №2: Перезагружать все вкладки когда новый SW установлен

Это слегка лучше, чем прошлый подход. В navigator.serviceWorker происходит эвентcontrollerchange ,когда новый SW получает контроль над текущей страницей. Это происходит сразу после прохождения этапа installing.
Теперь можно вызвать skipWaiting() во время установки, отловить эвент и заставить вкладку обновиться. Это будет выглядеть вот так:

navigator.serviceWorker.addEventListener('controllerchange',  ()  => window.location.reload());

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

Вариант №3: Дать пользователю самому вызвать обновление

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

Мы всё ещё перезагружаем страницу на срабатывании controllerchange, как и в предыдущем способе, но теперь пользователь знает о том, что это произойдёт и может этого избежать.
Для того, чтобы отследить новый SW, нам понадобится объект ServiceWorkerRegistration. Раньше мы просто вызывыли .register() и не знали, что этот метод возвращает промис с объектом регистрации. В этом API регистрации есть несколько интересных возможностей. Например, можно вызвать update(), чтобы обновить SW вручную. Обычно он делает это сам после регистрации, но вдруг вы хотите проверять наличие обновлений чаще.

Ссылку на текущий (active) SW можно получить через navigator.serviceWorker.controller из поля active в регистрации. Таким же образом можно достучаться до ожидающего (waiting) или устанавливающегося (installing) SW.

Любому SW можно отправить сообщение через postMessage(), если вы работали с iframe и передавали сообщения между окнами, вам знаком этот API. Внутри кода SW мы можем слушать это событие. Вы можете добавить следующий код в ваш SW.

addEventListener('message', ev => {  
  if (ev.data === 'skipWaiting') return skipWaiting();
});

Если вы используете Workbox или CRA, то примерно этот код там уже есть.

Дальше нам нужно отследить появление ожидающего SW. На мой взгляд лучше не реагировать каждый раз на SW со статусом installing, как это пишут в некоторых руководствах, а дождаться когда объект регистрации SW вернёт true в поле waiting. Это замедляет обновление, но не триггерит ваше модальное окно когда SW устанавливается в первый раз.

После того, как мы дождались ожидающий SW, вызываем модальное окно, в котором пользователь может подтвердить обновление. При подтверждении мы вызываем skipWaiting() и насильно перезагружаем страницу, как описано выше. При отказе обновление будет отложено. Выглядеть код в моём случае будет так :

// вызов модального окна
const askUserToUpdate = reg => {
  return Modal.confirm({
    onOk: async () => {
      // вешаем обработчик изменения состояния
      navigator.serviceWorker.addEventListener('controllerchange', () => {
        window.location.reload();
      });

      // пропускаем ожидание 
      if (reg && reg.waiting) {
        reg.waiting.postMessage({ type: 'SKIP_WAITING' });
      }
    },

    onCancel: () => {
      Modal.destroyAll();
    },
    icon: null,
    title: 'Хорошие новости! ? ',
    content:
      'Мы только что обновили версию приложения! Чтобы получить обновления, нажмите на кнопку ниже (страница перезагрузится)',
    cancelText: 'Не обновлять',
    okText: 'Обновить'
  });
};

// проверка регистрации
const registerValidSW = (swUrl, config) => {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      if (registration.waiting) {
        // оброботчик SW в ожидании
        askUserToUpdate(registration);
      }
    ...

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

Соглашусь, что у этого подхода есть и минусы. Во-первых, он сложный. Даже с примерами кода в него сложновато врубиться, пока вы сами не поигрались с SW и его регистрацией.

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

Однако, именно этот подход считается рекомендованным. Я уверен, вы видели его использование во многих сервисах. Он даже включен в Workbox Advanced Recipes, но лично меня удручает тот факт, что вам о нём не говорят сразу и нужно реально покопаться, а иногда и самому до него дойти.

Хотелось бы, чтобы обновление SW не приносило столько боли. Это явно точка роста для данной технологии.

Желаю вам удачи в обновлении ваших воркеров на проде :)

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 13

    0
    Спасибо. Можно посмотреть полный код SW?
      0
      В моём проекте (СRA) SW генерировался Workbox-ом, поэтому я лишь добавлял приведённый код в конфигурацию serviceWorker.js
      +1

      Шутка хороша, посмеялся.


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

        0
        Как по мне, её главный недостаток это непроработанная документация.
        0

        Спасибо за статью, только я так и не понял в чём смысл шутки. Кстати, я слышал другую ее версию: переименовал сервис-воркер — пора менять домен.

          0
          Как и в вашей версии, смысл в том, что по этому домену теперь поломанный сайт как его не обновляй :)
          +3
          Спасибо за опыт! Несколько лет назад тоже в продакшене использовал сервис воркеры. При доставки обновлений тоже показывал такое окно в углу экрана с двумя кнопками («обновить» и «отложить»), и еще с обратным таймером, по истечении которого автоматически обновлялось приложение. Дело в том, что клиенты вообще не нажимали никаких других кнопок, кроме тех, которые нужны им были для обработки заказов (менталитет видимо) и почти никогда не закрывали браузер и не перезагружали страницу, а обновления доставлять нужно было :)
          Позже планировали добавить возможность принудительно обновить на клиентах без таймера.
          Основная цели добавления сервис воркеров были: принудительные обновления клиентов и быстрая загрузка приложения. Цели были достигнуты, заказчики были довольны.

          Так как я использовал коробочный сервис воркер Ангуляра, в одной из версий (5-ой кажется), сломали сервис воркер так, что закешированный index.html не отдавался сервис воркером корректно, работа была парализованы до сброса браузера. Позже чтобы не натыкаться на эту штуку я просто добавлял в кеш все нужные файлы за исключением index.html. Целям это никак не противоречило, так как страница очень мало весит, но зато даже если сервис воркер ломался, но после F5 работало железно всегда.
            0
            Спасибо за интересный кейс :)

            На начальном этапе у меня тоже была идея исключить из кэша index.html, если хочется «починить» перезагрузку страницы — это хороший вариант.
            0
            Тимур, статья — огонь! Спасибо за неё!
            А чего конкретно не хватает в документации по PWA, скажем, на web.dev? Очень хотелось бы знать, над чем работать в 2021-м.
            И не хочу здесь спамить ссылкой без разрешения, но было бы здорово видеть Вас в нашем ТГ сообществе PWA, если Вы ещё не там ;) Если разрешите, с удовольствием приглашу и Вас, и всех читателей.
              +1
              Мой главный вопрос к документации по PWA в том, что обновление SW это целая техника и нужно до неё ещё докопаться. Я бы в целом тему lifecycle (обновления) PWA вынес бы в категорию или статью следующую сразу за созданием PWA. Но это моё личное мнение :)
                0
                А с этой статьёй сталкивались developers.google.com/web/fundamentals/primers/service-workers/lifecycle? Или она слишком глубоко закопана и её следовало бы подсветить? Или в ней не то и не о том?
                  +1
                  Да, конечно. В том и дело, что с проблемой ты сталкиваешься раньше чем с этой статьёй, особенно если используешь Workbox
              0

              А если использовать проверку на соединение и подгружать все ресурсы из памяти, когда интернет соединение отсутствует, но в ином случае всегда его скачивать?
              Или же анализировать разницу в размере страницы и если такая имеется, то грузить новую версию?

              Only users with full accounts can post comments. Log in, please.