(С)

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

  • кэширование страниц сайта для офлайн доступа;
  • ведение учета страниц, доступных офлайн, проброс необходимых данных;
  • мониторинг статуса сети, онлайн или офлайн;
  • общение сервис-воркера со страницами и вкладками, которые он обслуживает;
  • перехват сервис-воркером запроса на открытие адреса /offline/ и генерирование новой страницы прямо на устройстве, без запроса к серверу.

Если тема сервис-воркеров и Progressive Web Apps (PWA) для вас новая, то перед чтением этой статьи необходимо познакомиться с ними поближе.

Меня зовут Рыбин Павел, я работаю во фронтенд-разработке Медиапроектов Mail.Ru Group. Это руководство мне помогали писать грабли, набитые шишки и подводные камни, попавшиеся мне при реализации PWA для мобильной версии Авто Mail.Ru.

В тексте будут встречаться небольшие примеры кода, иллюстрирующие рассказ. Расширенную же демо-версию можно посмотреть на GitHub.

Подключение сервис-воркера


Сервис-воркер, обслуживающий весь сайт, должен располагаться в корне. Например, иметь адрес /service-worker.js. В нашем случае требуется именно такой. Если вы будете отдавать файл сервис-воркера из директории /js/, например /js/service-worker.js, то он сможет обрабатывать только те сетевые запросы, которые начинаются с /js/

Подключаем сервис-воркер со страницы нашего сайта:

// app.js - выполняется на странице сайта

// После полной загрузки страницы регистрируем service worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', registerServiceWorker);
}

function registerServiceWorker () {
  // файл подключается из корня сайта, а значит
  // сможет обрабатывать запросы на все подразделы
  navigator.serviceWorker.register('/service-worker.js')
    .then(registration => {
      if (!registration.active) {
        // Еще не активирован
        return;
      }

      // Сервис-воркер активирован, можно с ним работать.
      // Чуть позже мы добавим сюда вызовы необходимых функций.

  });
}

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

// service-worker.js

// Файлы, которые потребуются офлайн
const dependencies = [
  '/css/app.css',
  '/js/offline_page.js',
  '/img/logo.png',
  '/img/default_thumb.png'
];

// Фаза установки, сервис-воркер еще не активен
self.addEventListener('install', event => {

  // Загружаем все файлы, которые потребуются для offline-режима
  const loadDependencies = self.caches.open('myApp')
      .then(cache => cache.addAll(dependencies));

  // Сервис-воркер перейдет к следующему этапу своего цикла,
  // когда все необходимые файлы будут загружены и закэшированы
  event.waitUntil(loadDependencies);
});

Следующее событие activate. Оно нам пригодится для того, чтобы очистить старый кэш и записи в базе данных. В нашем примере для работы с IndexedDB используется простой хэлпер idb-keyval. Он и его более прокачанный брат idb являются удобными обертками, промисифицирующими работу с устаревшим морально API IndexedDB.

// service-worker.js

import { clear } from 'idb-keyval';

// Файлы, которые потребуются офлайн
const dependencies = [/* ... */];

// Активация
self.addEventListener('activate', event => {

  // чистим записи в IndexedDB
  const promiseClearIDB = clear();

  // чистим старый кэш
  const promiseClearCache = self.caches.open(cacheName)
    .then((cache) => cache.keys()
      .then((cacheKeys) => Promise.all(cacheKeys.map((request) => {
        // Удаляем все, кроме ресурсов из списка файлов,
        // которые потребуются офлайн
        const canDelete = !dependencies.includes(request.url);
        return canDelete ? cache.delete(request, {ignoreVary: true})
          : Promise.resolve();
      }))));

  const promiseClearAll = Promise.all([promiseClearIDB, promiseClearCache])
    .catch(err => console.error(error));

  // Жизненный цикл сервис-воркера продолжится,
  // когда будут очищены кэш и IndexedDB
  event.waitUntil(promiseClearAll);
});

После активации сервис-воркер готов к работе. Он сможет обрабатывать сетевые запросы и получать сообщения от всех страниц нашего сайта, которые были открыты после его активации. Нужно лишь добавить соответствующие обработчики событий. Именно здесь мы будем отлавливать запрос страницы /offline/.

// service-worker.js

// Обработка исходящих сетевых запросов
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  if (url.origin !== self.location.origin) {
    // Чужой домен, не обрабатываем этот запрос
    return fetch(request).catch(err => console.log(err));
  }

  // Проверяем, была ли запрошена страница /offline/
  const isOfflineListRequested = /^\/offline\//.test(url.pathname);

  const response = isOfflineListRequested
    // Создаем кастомный response со страницей
    // списка материалов, доступных офлайн
    ? createOfflineListResponse()
    
    // Делаем обычный запрос.
    // Здесь в зависимости от урла мы можем применять
    // разные стратегии кэширования, что-то сохранять "на века",
    // что-то каждый раз обновлять и т.д.
    : fetchWithOneOrAnotherCacheStrategy(event.request);

  event.respondWith(response);
});

Что такое «стратегии кэширования» и зачем они нужны?

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

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

  • можно кэшировать «навсегда»;
  • нельзя кэшировать надолго;
  • можно кэшировать, но при удобном случае обновить;
  • и так далее, ограничивают этот список лишь ваша фантазия и задачи бизнеса.

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

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

Регистрация страниц, доступных офлайн


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

  • При открытии страницы мы будем формировать объект данных, описывающих эту страницу (адрес, заголовок, адрес превью для вывода в списке).
  • После формирования данных, отправим их сервис-воркеру через postMessage.
  • Сервис-воркер получит данные и добавит их в общий список.

Скрипт, собирающий данные о странице будет выполняться на ней, являясь частью страницы. Так удобней. Это позволит пробрасывать необходимую информацию, например, через блок head и получать ее из верстки. Или любым иным, подходящим для вас способом.

Давайте воспользуемся микроразметкой Open Graph. Сегодня сложно представить себе сайт без нее. Кроме того, с ее помощью можно передать всю необходимую в нашем случае информацию:

<meta property="og:title" content="Homer Simpson" />
<meta property="og:url" content="http://example.com/homer.html" />
<meta property="og:image" content="http://example.com/homer.png" />

Зачем передавать адрес страницы через верстку? Почему не получить его в JS через объект location?

Сегодня большинство сайтов используют для аналитики всевозможные get-параметры, маркирующие, например, источник трафика. В результате получается, что адреса /homer.html, /homer.html?utm_source=vk и /homer.html?utm_source=email по факту ведут на одну и ту же страницу, а значит, должны быть зарегистрированы в списке один раз. Здесь нам и поможет «каноничный» адрес, переданный через og:url, он будет всегда одинаковым. Скорей всего вся необходимая og-разметка у вас уже есть, проверить ее полноту можно при помощи расширения для Google Chrome.

Итак, давайте научим страницу сообщать сервис-воркеру о том, что она загружена. Доработаем функцию registerServiceWorker (см. выше).

// app.js - выполняется на странице сайта

function registerServiceWorker () {
  navigator.serviceWorker.register('/service-worker.js')
    .then(registration => {
      if (!registration.active) {
        // Еще не активирован
        return;
      }
      // Сервис-воркер активирован, можно с ним работать
      // Сообщаем, что текущая страница теперь доступна офлайн
      registerPageAsCached();
  });
}

/**
 * Регистрирует текущую страницу сайта, как доступную офлайн
 */
function registerPageAsCached () {
  // Функцию getPageInfoFromHtml мы разбирать не будем,
  // главное, что она должна вернуть объект с полями:
  //   url - "каноничный" адрес страницы
  //   title - название страницы
  //   thumb - адрес превьюшки страницы
  const page = getPageInfoFromHtml();

  // Отправляем данные сервис-воркеру
  postMessage({
    action: 'registerPage',
    page
  });
}

/**
 * Отправка сообщения в service worker
 * @param {object} message
 */
function postMessage (message) {
  const {controller} = navigator.serviceWorker;
  if (controller) {
    controller.postMessage(message);
  }
}

Обратите внимание: в сообщении помимо данных о странице мы передаем поле action, описывающее тип сообщения. Это позволит нам в будущем передавать разные данные для разных целей.

Кто-то спросит, а откуда мы знаем, что страница закэширована?

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

Получаем данные со страницы в сервис-воркере:

// service-worker.js

import {get, set} from 'idb-keyval';

/*
 * Обработка сообщений со страниц
 */
self.addEventListener('message', event => {
  const {data = {}} = event;
  const {page} = data;

  // Сообщения могут быть разные,
  // разруливаем, используя action
  switch (data.action) {
    case 'registerPage':
      addToOfflineList(page);
      break;
  }
});

/**
 * Регистрирует страницу, как доступную офлайн
 * @param {object} pageInfo
 * @return {Promise}
 */
export function addToOfflineList (pageInfo) {
  // кэшируем превью страницы, используя подходящую стратегию,
  // изображение пригодится в offline режиме
  if (pageInfo.thumb) {
    fetchWithOneOrAnotherCacheStrategy(pageInfo.thumb);
  }

  // добавляем информацию о странице в IndexedDB
  return get('cachedPages')
    .then((pages = {}) => set('cachedPages', {
      ...pages,
      [pageInfo.url]: pageInfo
    }));
}

Страница зарегистрирована.

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

Мониторинг статуса подключения к сети


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

Можно сделать недоступные материалы тусклыми, визуально выделив закэшированные:



А можно наоборот, выделять статьи, хранящиеся в кэше, специальным значком, как это сделано на Авто Mail.Ru:



В скрипте, выполняющемся на странице, создадим функцию ping, которая будет периодически вызываться через указанный интервал и отправлять сообщение в сервис-воркер.

// app.js - выполняется на странице сайта

const PING_INTERVAL = 10000; // 10 секунд

function registerServiceWorker () {
  navigator.serviceWorker.register('/service-worker.js')
    .then(registration => {
      if (!registration.active) {
        // Еще не активирован
        return;
      }
      // Сервис-воркер активирован, можно с ним работать
      registerPageAsCached(); // см. выше

      // запускаем пинг
      ping();
  });
}

/**
 * Периодическая проверка доступности сети (нашего сервера)
 */
function ping() {
  postMessage({
    action: 'ping',
  });
   setTimeout(ping, PING_INTERVAL);
}

/**
 * Отправка сообщения в service worker
 * @param {object} message
 */
function postMessage (message) {
  const {controller} = navigator.serviceWorker;
  if (controller) {
    controller.postMessage(message);
  }
}

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

// service-worker.js

import {get} from 'idb-keyval';

/*
 * Обработка сообщений со страниц
 */
self.addEventListener('message', event => {
  const {data = {}} = event;
  const {page} = data;

  // Сообщения могут быть разные,
  // разруливаем, используя action
  switch (data.action) {
    case 'ping':
      ping();
      break;
  }
});

/**
 * Проверка доступности сервера
 */
export function ping () {
  fetch('/ping.gif').then(
    () => pingHandler(true),
    () => pingHandler(false)
  );
}

/**
 * Журналирует и отправляет сообщение на страницу
 * об успешности или неуспешности пинга
 * @param {boolean} isOnline
 */
function pingHandler (isOnline) {
  postMessage({
    action: 'ping',
    online: isOnline,
  });
}

/**
 * Отправляет данные на все страницы и вкладки,
 * обслуживаемые сервис-воркером
 * @param {object} message
 */
function postMessage (message) {
  // Находим все открытые страницы и вкладки нашего сайта
  self.clients.matchAll().then(clients => {

    // При отсутствии сети добавляем в сообщение
    // список закэшированных страниц
    const offlinePagesPromise = message.online ?
      Promise.resolve()
      : get('cachedPages');

    offlinePagesPromise.then(offlinePages => {
      if (offlinePages) {
        message.offlinePages = offlinePages;
      }
      clients.forEach(client => {
        // Клиент может пропасть, делаем проверку
        if (client) {
          client.postMessage(message);
        }
      });
    });
  });
}

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

Страница, получив отчет, производит со своим содержимым необходимые манипуляции:

// app.js - выполняется на странице сайта

let isOnline = true;

function registerServiceWorker () {
  navigator.serviceWorker.register('/service-worker.js')
    .then(registration => {
      if (!registration.active) {
        // Еще не активирован
        return;
      }

      // Подписыватеся на сообщения от сервис-воркера
      serviceWorker.addEventListener('message', handleMessage);

      registerPageAsCached(); // см. выше
      ping(); // см. выше
  });
}
/**
 * Обработка сообщения из service worker-а
 * @param {MessageEvent} e
 */
function handleMessage (e) {
  const {data} = e;
  if (data.action === 'ping' && isOnline !== data.online) {
    isOnline = data.online;
    toggleNetworkState(data);
  }
}

/**
 * Переключает состояние онлайн/оффлайн страницы
 * @param {object} params
 */
function toggleNetworkState (params) {
  const {online, offlinePages = {}} = params;

  // Вешаем глобальный модификатор,
  // в нашем примере он заставит все ссылки "потускнеть"
  document.body.classList.toggle('offline', !online);

  // Для офлайн режима подсвечиваем закэшированные ссылки
  if (!online) {
    Array.from(document.links).forEach(link => {
      const href = link.getAttribute('href');
      const isCached = !!offlinePages[href] || href === '/offline/';
      link.classList.toggle('cached', isCached);
    });
  }
}

Создание страницы /offline/ сервис-воркером


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

Я в демо-версии использовал простой шаблонизатор pug. Однако, можно использовать любой другой вплоть до «серверного рендера» для изоморфного приложения на React.

Мой шаблон выглядит так:

html(lang="en")
  head
    title Available offline
    link(rel="stylesheet" href="/css/app.css")

  body
    section.layout
      header.layout__header
        a.layout__header__logo(href="/")

      h1 You can read it offline

      ul.articles-list
        each page in pages
          li.articles-list__item
            a(href=page.url)
              if page.thumb
                img.avatar(src=page.thumb alt="")
              else
                img.avatar(src="/img/default_thumb.png" alt="")
              span=page.title

В сервис-воркере, в обработчике события fetch выделяем запрос к /offline/ и возвращаем ничего не подозревающему браузеру свежесозданную страницу:

// service-worker.js

import {get} from 'idb-keyval';
const template = require('offlinePage.pug');

// Обработка исходящих сетевых запросов
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  if (url.origin !== self.location.origin) {
    // Чужой домен, не обрабатываем этот запрос
    return fetch(request).catch(err => console.log(err));
  }

  // Проверяем, была ли запрошена страница /offline/
  const isOfflineListRequested = /^\/offline\//.test(url.pathname);

  const response = isOfflineListRequested
    // Наш кейс!
    // Создаем кастомный response со страницей
    // списка материалов, доступных офлайн
    ? createOfflineListResponse()
    
    // Обычный запрос
    : fetchWithOneOrAnotherCacheStrategy(event.request);

  event.respondWith(response);
});

/**
 * Формирует объект response с данными о страницах,
 * доступных офлайн
 * @return {Promise<Response>}
 */
function createOfflineListResponse () {
  // Получаем данные об офлайн страницах
  return get('cachedPages')
    .then((pagesList = {}) => {

      // Передаем в шаблон список доступных страниц
      const html = template({
        pages: Object.values(pagesList)
      });

      // Создаем и возвращаем объект ответа
      const blob = new Blob([html], {
        type: 'text/html; charset=utf-8'
      });
      return new Response(blob, {
        status: 200,
        statusText: 'OK'
      });
    }).catch(err => console.error(err));
}

Результат:



Напоследок


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

Есть смысл держать в кэше ресурсы, которые требуются регулярно: CSS, JS, изображения элементов интерфейса. Для остальных следует придумать какое-нибудь «правило протухания». Например, удалять всё, что не запрашивалось более трех (пяти, десяти, года?) дней.

Для удобства отладки пригодится подробное журналирование каждого из этапов. Для этого можно создать свою утилиту log, которая внутри умеет включаться/выключаться по флагу из окружения, и выводить информацию через нее. В отличие от страниц, сервис-воркер продолжает жить и между их перезагрузками и закрытием, поэтому рекомендую в консоли инструментов разработчика включить чекбокс Preserve log.

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

Полезные ссылки


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


  1. 3axap4eHko
    11.04.2018 15:31

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


    1. drfisher Автор
      11.04.2018 15:50

      С этой страницей ничего не делаем. Если пользователь вернется на нее позже, закэшируем. В противном случае она так и останется за рамками.
      Теоретически ее можно попробовать поймать через self.clients при событии install и загрузить повторно, но я не пробовал, есть подозрение, что и там не будет доступна. Не думаю, что игра стоит свеч.
      На Авто мы параллельно кэшируем некоторые «важные» страницы отдельным механизмом. Если есть подозрение, что страница, ставшая стартовой для сервис вокера, важна, то можно поступать так же — составить свой список «важных» и подгружать их.


  1. springimport
    11.04.2018 18:57

    Как и всегда — работает, но через огромное количество костылей, и я не про ваш код — он крут.
    Хотелось бы какой-то более лучшей поддержки со стороны браузеров. Видимо, нужно ждать свою «jQuery» для этого.


  1. dkrnl
    12.04.2018 17:21

    Привет! Что думаете о sw-precache/sw-toolbox и Workbox от Google Chrome Labs?


    1. drfisher Автор
      12.04.2018 17:46

      Это отличные тулзы. На мой взгляд, Workbox является правильным выбором в 95-99% случаев, когда принимается решение о поддержке сайтом офлайна: удобные настройки, стратегии кэширования и инвалидация кэша из коробки. Писать что-то свое сегодня нужно только, если хочется странного или есть интерес к экспериментам и время на них.
      Если передо мной встанет задача прикрутить офлайн, скорей всего я возьму Workbox.


      1. dkrnl
        12.04.2018 18:02

        Мы используем sw-precache-webpack-plugin в нескольких проектах, работает отлично, но минус большой — почти не реально разобраться и поправить, много не читаемого фарш-кода. Насколько я понял разработчиков: Workbox это переосмысление опыта sw-toolbox, больше модульности, вроде как фреймворк для сервисворкеров оффлайн. Однако заставить работать Workbox не удалость — тогда он был в бэте.


        1. drfisher Автор
          12.04.2018 18:08

          Вот тут есть две пошаговые лабы от Гугл, если сделать фильтрацию по Workbox в верхнем поле поиска. Одна из них как раз про переезд со «старых» инструментов. Возможно, пригодится codelabs.developers.google.com


          1. dkrnl
            12.04.2018 18:16

            О! Спасибо за ссылку. Статья очень свежая — Updated Apr 9, 2018 :-)