Рецепты по приготовлению оффлайн-приложений

Автор оригинала: Jake Archibald
  • Перевод


Доброго времени суток, друзья!

Представляю вашему вниманию перевод замечательной статьи Джейка Арчибальда «Offline Cookbook», посвященной различным вариантам использования сервис-воркера (ServiceWorker API, далее по тексту — просто воркер) и интерфейса кэширования (Cache API).

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

Если не знакомы, то начните с MDN, а затем возвращайтесь. Вот еще неплохая статья про сервис-воркеры специально для визуалов.

Без дальнейших предисловий.

В какой момент сохранять ресурсы?


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

Первый вопрос: когда следует кэшировать ресурсы?

При установке как зависимость



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

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

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

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('mysite-static-v3')
            .then(cache => cache.addAll([
                '/css/whatever-v3.css',
                '/css/imgs/sprites-v6.png',
                '/css/fonts/whatever-v8.woff',
                '/js/all-min-v4.js'
                // и т.д.
            ]))
    )
})

event.waitUntil принимает промис для определения продолжительности и результата установки. Если промис будет отклонен, воркер не будет установлен. caches.open и cache.addAll возвращают промисы. Если один из ресурсов не будет получен,
вызов cache.addAll будет отклонен.

При установке не как зависимость



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

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

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('mygame-core-v1')
            .then(cache => {
                cache.addAll(
                    // уровни 11-20
                )
                return cache.addAll(
                    // ключевые ресурсы и уровни 1-10
                )
            })
    )
})

Мы не передаем промис cache.addAll в event.waitUntil для уровней 11-20, так что если он будет отклонен, то игра все равно будет работать оффлайн. Разумеется, вам следует позаботиться о решении возможных проблем с кэшированием первых уровней и, например, повторить попытку кэширования в случае провала.

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

Прим. пер.: данный интерфейс был реализован в конце 2018 года и получил название Background Fetch, но пока работает только в Хроме и Опере (68% по данным CanIUse).

При активации



Подходит для удаления старого кэша и миграции.

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

self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys()
            .then(cacheNames => Promise.all(
                cacheNames.filter(cacheName => {
                    // если true, значит, мы хотим удалить данный кэш,
                    // но помните, что он используется во всем источнике
                }).map(cacheName => caches.delete(cacheName))
            ))
    )
})

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

При возникновении пользовательского события



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

Предоставьте пользователю кнопку «Прочитать позже» или «Сохранить». При нажатии кнопки получите ресурс и запишите его в кэш.

document.querySelector('.cache-article').addEventListener('click', event => {
    event.preventDefault()

    const id = event.target.dataset.id
    caches.open(`mysite-article ${id}`)
        .then(cache => fetch(`/get-article-urls?id=${id}`)
            .then(response => {
                // get-article-urls возвращает массив в формате JSON
                // с URL для данной статьи
                return response.json()
            }).then(urls => cache.addAll(urls)))
})

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

Во время получения ответа



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

Если запрашиваемого ресурса нет в кэше, получаем его из сети, отправляем клиенту и записываем в кэш.

Если вы запрашиваете несколько URL, таких как пути к аватарам, убедитесь, что это не приведет к переполнению хранилища источника (origin — протокол, хост и порт) — если пользователю потребуется освободить место на диске, вы не должны оказаться первыми. Позаботьтесь об удалении ненужных ресурсов.

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.open('mysite-dynamic')
            .then(cache => cache.match(event.request)
                .then(response => response || fetch(event.request)
                    .then(response => {
                        cache.put(event.request, response.clone())
                        return response
                    })))
    )
})

Для эффективного использования памяти мы читаем тело ответа только один раз. В приведенном примере метод clone используется для создания копии ответа. Это делается для того, чтобы одновременно отправить ответ клиенту и записать его в кэш.

Во время проверки на новизну



Подходит для обновления ресурсов, не требующих последних версий. Это может относиться и к аватарам.

Если ресурс имеется в кэше, используем его, но получаем обновление при следующем запросе.

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.open('mysite-dynamic')
            .then(cache => cache.match(event.request)
                .then(response => {
                    const fetchPromise = fetch(event.request)
                        .then(networkResponse => {
                            cache.put(event.request, networkResponse.clone())
                            return networkResponse
                        })
                        return response || fetchPromise
                    }))
    )
})

При получении пуш-уведомления



Интерфейс уведомлений (Push API) — это абстракция над воркером. Она позволяет воркеру запускаться в ответ на сообщение от операционной системы. Причем, это происходит независимо от пользователя (при закрытой вкладке браузера). Страница, как правило, отправляет пользователю запрос на предоставление разрешения для совершения определенных действий.

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

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

Без подключения к сети Twitter не предоставляет контент, связанный с уведомлением. Тем не менее, клик по уведомлению приводит к его удалению. Не делайте так!

Следующий код обновляет кэш перед отправкой уведомления:

self.addEventListener('push', event => {
    if (event.data.text() === 'new-email') {
        event.waitUntil(
            caches.open('mysite-dynamic')
                .then(cache => fetch('/inbox.json')
                    .then(response => {
                        cache.put('/inbox.json', response.clone())
                        return response.json()
                    })).then(emails => {
                        registration.showNotification('New email', {
                            body: `From ${emails[0].from.name}`,
                            tag: 'new-email'
                        })
                    })
        )
    }
})

self.addEventListener('notificationclick', event => {
    if (event.notification.tag === 'new-email') {
        // предположим, что все ресурсы, необходимые для рендеринга /inbox/ были кэшированы,
        // например, при установке воркера
        new WindowClient('/inbox/')
    }
})

При фоновой синхронизации



Фоновая синхронизация (Background Sync) — еще одна абстракция над воркером. Она позволяет запрашивать разовую или периодическую фоновую синхронизацию данных. Это также не зависит от пользователя. Однако, ему также оправляется запрос на разрешение.

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

self.addEventListener('sync', event => {
    if (event.id === 'update-leaderboard') {
        event.waitUntil(
            caches.open('mygame-dynamic')
                .then(cache => cache.add('/leaderboard.json'))
        )
    }
})

Сохранение кэша


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

Размеры хранилищ не являются фиксированными и зависят от устройства, а также от условий хранения ресурсов. Это можно проверить так:

navigator.storageQuota.queryInfo('temporary').then(info => {
    console.log(info.quota)
    // результат: <квота в байтах>
    console.log(info.usage)
    // результат <размер хранящихся данных в байтах>
})

Когда размер того или иного хранилища достигает лимита, это хранилище очищается по определенным правилам, которые в настоящее время нельзя изменить.

Чтобы решить эту проблему был предложен интерфейс отправки запроса на разрешение (requestPersistent):

navigator.storage.requestPersistent().then(granted => {
    if (granted) {
        // ура, данные сохранятся за счет увеличения размера хранилища
    }
})

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

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

Ответы на запросы


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

Только кэш



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

self.addEventListener('fetch', event => {
    // если не будет найдено совпадение,
    // ответ будет похож на ошибку соединения
    event.respondWith(caches.match(event.request))
})

Только сеть



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

self.addEventListener('fetch', event => {
    event.respondWith(fetch(event.request))
    // или просто не вызывайте event.respondWith
    // это приведет к стандартному поведению браузера
})

Сначала кэш, затем, при неудаче, сеть



Подходит для обработки большинства запросов в оффлайн-приложениях.

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request)
            .then(response => response || fetch(event.request))
    )
})

Сохраненные ресурсы возвращаются из кэша, несохраненные — из сети.

Кто успел, тот и съел



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

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

// Promise.race нам не подойдет, поскольку он отклоняется
// при отклонении любого из переданных ему промисов.
// Напишем собственную функцию
const promiseAny = promises => new Promise((resolve, reject) => {
    // все promises должны быть промисами
    promises = promises.map(p => Promise.resolve(p))
    // выполняем текущий промис, как только он разрешается
    promises.forEach(p => p.then(resolve))
    // если все промисы были отклонены, останавливаем выполнение функции
    promises.reduce((a, b) => a.catch(() => b))
        .catch(() => reject(Error('Все промисы отклонены')))
})

self.addEventListener('fetch', event => {
    event.respondWith(
        promiseAny([
            caches.match(event.request),
            fetch(event.request)
        ])
    )
})

Прим. пер.: сейчас для этой цели можно использовать Promise.allSettled, но его поддержка браузерами составляет 80%: -20% пользователей — это, пожалуй, слишком.

Сначала сеть, затем, при неудаче, кэш



Подходит для ресурсов, которые часто обновляются и не влияют на текущую версию сайта, например, статьи, аватары, новостные ленты в социальных сетях, рейтинг игроков и т.д.

Это означает, что вы предоставляете онлайн-пользователям новый контент, а оффлайн-пользователям — старый. Если запрос ресурса из сети завершился успешно, вероятно, следует обновить кэш.

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

self.addEventListener('fetch', event => {
    event.respondWith(
        fetch(event.request).catch(() => caches.match(event.request))
    )
})

Сначала кэш, затем сеть



Подходит для часто обновляемых ресурсов.

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

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

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

Код на странице:

const networkDataReceived = false

startSpinner()

// получаем свежие данные
const networkUpdate = fetch('/data.json')
    .then(response => response.json())
        .then(data => {
            networkDataReceived = true
            updatePage(data)
        })

// получаем сохраненные данные
caches.match('/data.json')
    .then(response => {
        if (!response) throw Error('Данные отсутствуют')
        return response.json()
    }).then(data => {
        // не перезаписывайте новые данные из сети
        if (!networkDataReceived) {
            updatePage(data)
        }
    }).catch(() => {
        // мы не получили данные из кэша, сеть - наша последняя надежда
        return networkUpdate
    }).catch(showErrorMessage).then(stopSpinner)

Код воркера:

Мы обращаемся к сети и обновляем кэш.

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.open('mysite-dynamic')
            .then(cache => fetch(event.request)
                .then(response => {
                    cache.put(event.request, response.clone())
                    return response
                }))
    )
})

Подстраховка



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

Подходит для местозаполнителей (замена изображений пустышками), неудачных POST-запросов, страниц «Недоступно при отсутствии подключения к сети».

self.addEventListener('fetch', event => {
    event.respondWith(
        // пробуем получить ресурс из кэша
        // если не получилось, обращаемся к сети
        caches.match(event.request)
            .then(response => response || fetch(event.request))
            .catch(() => {
                // если оба запроса провалились, используем страховку
                return caches.match('/offline.html')
                // у вас может быть несколько запасных вариантов
                // в зависимости от URL или заголовков запроса
            })
    )
})

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

Создание разметки на стороне воркера



Подходит для страниц, которые рендерятся на стороне сервера и не могут быть сохранены в кэше.

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

import './templating-engine.js'

self.addEventListener('fetch', event => {
    const requestURL = new URL(event.request.url)

    event.respondWith(
        Promise.all([
            caches.match('/article-template.html')
                .then(response => response.text()),
            caches.match(`${requestURL.path}.json`)
                .then(response => response.json())
        ]).then(responses => {
            const template = responses[0]
            const data = responses[1]

            return new Response(renderTemplate(template, data), {
                headers: {
                    'Content-Type': 'text/html'
                }
            })
        })
    )
})

Все вместе

Вам не обязательно ограничиваться одним шаблоном. Скорее всего, вам придется их комбинировать в зависимости от запроса. Например, в trained-to-thrill используется следующее:

  • Кэширование при установке воркера для постоянных элементов пользовательского интерфейса
  • Кэширование при получении ответа от сервера для изображений и данных Flickr
  • Получение данных из кэша, а при неудаче из сети для большинства запросов
  • Получение ресурсов из кэша, а затем из сети для результатов поиска Flick

Просто смотрите на запрос и решайте, что с ним делать:

self.addEventListener('fetch', event => {
    // разбираем URL
    const requestURL = new URL(event.request.url)

    // обрабатываем запросы к определенному хосту особым образом
    if (requestURL.hostname === 'api.example.com') {
        event.respondWith(/* определенная комбинация шаблонов */)
        return
    }

    // маршрутизация для относительных путей
    if (requestURL.origin === location.origin) {
        // обработка различных путей
        if (/^\/article\//.test(requestURL.pathname)) {
            event.respondWith(/* другая комбинация шаблонов */)
            return
        }
        if (/\.webp$/.test(requestURL.pathname)) {
            event.respondWith(/* другая комбинация шаблонов */)
            return
        }
        if (request.method == 'POST') {
            event.respondWith(/*  другая комбинация шаблонов */)
            return
        }
        if (/cheese/.test(requestURL.pathname)) {
            event.respondWith(
                // прим. пер.: не смог перевести - Вопиющая ошибка сыра?
                new Response('Flagrant cheese error', {
                // такого статуса не существует
                status: 512
                })
            )
            return
        }
    }

    // общий паттерн
    event.respondWith(
        caches.match(event.request)
            .then(response => response || fetch(event.request))
    )
})

Надеюсь, статья была вам полезной. Благодарю за внимание.

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

Средняя зарплата в IT

113 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 5 381 анкеты, за 2-ое пол. 2020 года Узнать свою зарплату
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    +1
    Легкий оффтоп — в рамках упражнения «понимаю промисы лучше» попробовал переделать
    Вот этот промис-hell
    self.addEventListener('fetch', event => {
        event.respondWith(
            caches.open('mysite-dynamic')
                .then(cache => cache.match(event.request)
                    .then(response => {
                        const fetchPromise = fetch(event.request)
                            .then(networkResponse => {
                                cache.put(event.request, networkResponse.clone())
                                return networkResponse
                            })
                            return response || fetchPromise
                        }))
        )
    })
    

    во что-то менее адовое, (но без использования async/await):

    self.addEventListener('fetch', event => {
      const cache$ = caches.open('mysite-dynamic');
    
      const refreshCache = () => Promise.all([
        fetch(event.request),
        cache$
      ]).then(([networkResponse, cache]) => {
        cache.put(event.request, networkResponse.clone())
        return networkResponse;
      });
    
      event.respondWith(
        cache$
          .then(cache => cache.match(event.request))
          .then(cacheResponse => {
            const refreshedCache$ = refreshCache();
            return cacheResponse || refreshedCache$;
          })
      )
    })

    как вам?
      +1

      Тут стоит предупредить, что нужно осторожно обращаться с респонсами. Если вы попробуете один респонс (без клонирования) отправить в кеш и вернуть из fetch, то в ряде случаев будете наблюдать битый респонс в браузере (во всяком случае в Хроме).

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

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