Русский
Русский
English
Статистика
Реклама

Serviceworker

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

03.09.2020 14:17:53 | Автор: admin


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

Представляю вашему вниманию перевод замечательной статьи Джейка Арчибальда 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 = falsestartSpinner()// получаем свежие данные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))    )})

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

WorkBox ваш toolkit в мире сервис-воркеров

13.10.2020 12:18:27 | Автор: admin

Привет, Хабр!

Меня зовут Святослав. Я работаю в компании ДомКлик и отвечаю за развитие сервисов оформления ипотеки. В начале года мы взяли курс на внедрение философии Progressive Web Application (PWA) в наших клиентских приложениях.

Одним из важных аспектов PWA является использование технологии Service Worker API, выполняющей роль прокси между браузером и сервером. Это позволяет поддерживать работу приложения в оффлайн-режиме, управлять кэшированием сетевых данных с их фоновой синхронизацией, а также работать с push-уведомлениями.

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

Workbox это разработанный в Google набор инструментов, предоставляющих высокоуровневый API для работы с такими браузерными технологиями, как Service Worker API и Cache Storage API. Инструментарий состоит из набора изолированных модулей, которые помогут вам сделать полноценное PWA-приложение.

Входящие в состав Workbox модули.Входящие в состав Workbox модули.

Вы можете работать с сервис-воркерами нативно, однако использование Workbox даст вам значительные преимущества. Давайте рассмотрим их.

Управление кэшированием

Зачастую сложно сформировать единую политику кэширования для всех запрашиваемых ресурсов. К примеру, можно себе позволить кэшировать шрифты на долгий срок и не нагружать сеть запросами, но мы не можем кэшировать запросы, отвечающие за предоставление актуальных данных, влияющих на бизнес-процесс (например, ипотечную ставку или срок кредитования). Здесь нам на помощь приходит Workbox, который предлагает тонкую настройку стратегий кэширования, вплоть до каждого запроса. Разберём особенности работы каждой из них.

Network Only

При получении запроса сервис-воркер перенаправляет его в сеть. Кэш не используется.

Cache Only

Сервис-воркер формирует ответ на запрос только из кэша. Сеть не используется. Эта стратегия будет полезна, если у вас используется предварительное кэширование.

Network First

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

Cache First

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

Stale While Revalidate

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

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

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

import {registerRoute} from 'workbox-routing';import {CacheFirst} from 'workbox-strategies';import {CacheableResponsePlugin} from 'workbox-cacheable-response';import {ExpirationPlugin} from 'workbox-expiration';registerRoute(  ({request}) => request.destination === 'image',  new CacheFirst({    cacheName: 'assets',    plugins: [      new CacheableResponsePlugin({        statuses: [0, 200]      }),      new ExpirationPlugin({        maxEntries: 60,        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days      })    ]  }));

Кэширование потоковых аудио- и видеоданных

Некоторые HTTP-запросы включают в себя заголовок Range:, который говорит серверу о том, чтобы тот вернул часть запрашиваемого ресурса. Такие запросы используются в случае потоковых аудио- и видеоданных для реализации чанковой загрузки информации, что является более выигрышной альтернативой по сравнению с одинарным запросом на весь объём данных.

Workbox содержит модуль workbox-range-requests, который реализует всю необходимую логику для поддержки чанковой загрузки с участием кэша.

Простой пример работы с этим модулем:

import {registerRoute} from 'workbox-routing';import {CacheFirst} from 'workbox-strategies';import {RangeRequestsPlugin} from 'workbox-range-requests';registerRoute(  ({url}) => url.pathname.endsWith('.mp4'),  new CacheFirst({    plugins: [      new RangeRequestsPlugin(),    ]  }););

Журналирование

Отладка работы сервис-воркера является неотъемлемой частью процесса разработки PWA-приложений. У Workbox есть отладочный режим, который позволяет выводить в консоль браузера подробную информацию о работе вашего сервис-воркера. Опираясь на журналы, разработчик сможет намного быстрее добраться до корня возникшей проблемы.

Кроссбраузерная работа

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

К примеру:

  • Модуль оповещения об обновлении кэшированных данных (workbox-broadcast-cache-update) использует под капотом Broadcast Channel API. А если браузер его не поддерживает, то переключается на механизм postMessage.

  • Модуль фоновой синхронизации данных (workbox-background-sync) использует Background Sync API. При отсутствии браузерной поддержки модуль попытается повторить запрос из очереди событий во время следующего запуска сервис-воркера.

Интеграция с Google Analytics

При работе в оффлайн-режиме важно понимать, как пользователи взаимодействуют с вашим приложением. Но для работы сервисов аналитики (например, Google Analytics) важно наличие интернет-соединения для отправки отчетов на сервер. При отсутствии соединения данные о пользователе будут потеряны, и итоговый аналитический отчет окажется некорректен.

Модуль Workbox Google Analytics создан для решения этой проблемы. При оффлайн-работе он отлавливает неудачные запросы и сохраняет их в локальную базу данных браузера IndexedDB. А при возобновлении интернет-соединения запросы повторно отправляются на серверы Google Analytics.

Простой пример подключения этого модуля:

import * as googleAnalytics from 'workbox-google-analytics';googleAnalytics.initialize();

Способы использования

Workbox предлагает следующие варианты использования:

Работа с Webpack

Давайте рассмотрим один из способов использования Workbox вместе со сборщиком статических модулей Webpack. Для начала нам нужно установить плагин, который поставляется в виде npm-пакета:

npm install workbox-webpack-plugin --save-dev

Далее нужно подключить плагин в конфигурационном файле webpack.config.js, чтобы webpack автоматически сгенерировал воркер-файл на этапе сборки приложения.

Возьмем за основу пример из официальной документации. По умолчанию Workbox добавляет в предварительный кэш все файлы, которые участвуют в webpack-сборке. Но было бы ошибкой кэшировать все изображения вашего приложения, когда их число измеряется сотнями, а общий размер мегабайтами (нужно помнить, что браузер имеет квоту на хранение данных в Cache Storage). Вместо этого выставим такие параметры, чтобы сервис-воркер кэшировал изображения только тогда, когда приложение обращается за их загрузкой. А также установим лимит в 10 записей.

// Inside of webpack.config.js:const WorkboxPlugin = require('workbox-webpack-plugin');module.exports = {  // Other webpack config...  plugins: [    // Other plugins...    new WorkboxPlugin.GenerateSW({      // Do not precache images      exclude: [/\.(?:png|jpg|jpeg|svg)$/],      // Define runtime caching rules.      runtimeCaching: [{        // Match any request that ends with .png, .jpg, .jpeg or .svg.        urlPattern: /\.(?:png|jpg|jpeg|svg)$/,        // Apply a cache-first strategy.        handler: 'CacheFirst',        options: {          // Use a custom cache name.          cacheName: 'images',          // Only cache 10 images.          expiration: {            maxEntries: 10,          }        }      }]    })  ]};

На выходе мы получим сгенерированный файл sw.js с определенными правилами кэширования сетевых данных.

Резюме

Workbox делает работу с сервис-воркерами более комфортной. Этот инструмент позволяет декларативно определить правила кэширования ресурсов приложения, взяв низкоуровневую работу на себя. К тому же Workbox уже интегрирован с такими инструментами разработки, как react-create-app, vue-cli, preact-cli, next.js, что говорит о его признании со стороны сообщества разработчиков.

Подробнее..

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

29.12.2020 02:22:15 | Автор: admin

Слышали шутку о том, что если установил 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: 'Хорошие новости! 
Подробнее..

Браузерные Push-уведомления на Javascript и PHP

10.06.2021 02:22:28 | Автор: admin

Предисловие

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

В данной статье не будут "размусолены" принципы работы и тонкости Push уведомлений, только код, только хардкор.

Важные замечания

Push-уведомления работают только с HTTPS.
К слову, в добавок с HTTPS должен присутствовать валидный SSL сертификат, подойдет и Let's Encrypt

Для разработки подойдёт localhost. Проблем возникнуть не должно, но если все же возникли данная статья поможет разобраться с ними.

Да будет код

Авторизация (VAPID)

Для начала стоит установить библиотеку WebPush в ваш php проект:

$ composer require minishlink/web-push

Далее для авторизации вашего сервера браузером (VAPID), вам нужно сгенерировать публичный и приватный ssh ключи. Данные ключи понадобятся как на сервере, так и на клиенте (за исключением того что на клиенте нужен лишь публичный).

Чтобы сгенерировать несжатый публичный и приватный ключ, закодированный в Base64, введите следующее в свой Linux bash:

$ openssl ecparam -genkey -name prime256v1 -out private_key.pem$ openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt$ openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt

Так же автор библиотеки предоставляет генерацию vapid ключей с помощью встроенного метода:

$vapidKeysInBase64 = VAPID::createVapidKeys();

Подписка

Этап 1 (JS)

В начале стоит проверить наличие поддержки ServiceWorker, PushManager, а так же showNotification в браузере:

app.js

function checkNotificationSupported() {return new Promise((fulfilled, reject) => {  if (!('serviceWorker' in navigator)) {      reject(new Error('Service workers are not supported by this browser'));      return;    }    if (!('PushManager' in window)) {      reject(new Error('Push notifications are not supported by this browser'));      return;    }    if (!('showNotification' in ServiceWorkerRegistration.prototype)) {      reject(new Error('Notifications are not supported by this browser'));    return;    }        fulfilled();  })}

Создаем файл sw.js и далее регистрируем его:

app.js

navigator.serviceWorker.register('sw.js').then(() => {      console.log('[SW] Service worker has been registered');    }, e => {      console.error('[SW] Service worker registration failed', e);    }  );

Так же нам понадобится функция для проверки состояния подписки:

app.js

function checkNotificationPermission() {    return new Promise((fulfilled, reject) => {        if (Notification.permission === 'denied') {            return reject(new Error('Push messages are blocked.'));        }        if (Notification.permission === 'granted') {            return fulfilled();        }        if (Notification.permission === 'default') {            return Notification.requestPermission().then(result => {                if (result !== 'granted') {                    reject(new Error('Bad permission result'));                } else {                    fulfilled();                }            });        }        return reject(new Error('Unknown permission'));    });}

С сервера нам нужно получить публичный ssh ключ сгенерированный выше:

<script>window.applicationServerKey = '<?= $yourPublicKeyFromServer ?>'</script>

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

app.js

document.addEventListener('DOMContentLoaded', documentLoadHandler);function documentLoadHandler() {    checkNotificationSupported()        .then(() => {          setTimeout(() => {            serviceWorkerRegistration.pushManager.subscribe({                    userVisibleOnly: true,                    applicationServerKey: urlBase64ToUint8Array(window.applicationServerKey),                })                .then(successSubscriptionHandler, errorSubscriptionHandler)          }, 10000);         },         console.error      );}function urlBase64ToUint8Array(base64String) {    const padding = '='.repeat((4 - (base64String.length % 4)) % 4);    const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');    const rawData = window.atob(base64);    const outputArray = new Uint8Array(rawData.length);    for (let i = 0; i < rawData.length; ++i) {        outputArray[i] = rawData.charCodeAt(i);    }    return outputArray;}function errorSubscriptionHandler(err) {    if (Notification.permission === 'denied') {        console.warn('Notifications are denied by the user.');    } else {        console.error('Impossible to subscribe to push notifications', err);    }}

Далее если процесс получения разрешения подписки прошел успешно вызываем функцию successSubscriptionHandler

Формируем данные пользователя для дальнейшей отправки уведомлений.

app.js

function successSubscriptionHandler(subscriptionData) {    const key = subscription.getKey('p256dh');    const token = subscription.getKey('auth');    const contentEncoding = (PushManager.supportedContentEncodings || ['aesgcm'])[0];    const body = new FormData();    body.set('endpoint', subscription.endpoint);    body.set('publicKey', key ? btoa(String.fromCharCode.apply(null, new Uint8Array(key))) : null);    body.set('authToken', token ? btoa(String.fromCharCode.apply(null, new Uint8Array(token))) : null);    body.set('contentEncoding', contentEncoding);    return fetch('src/push_subscription.php', {      method,      body,    }).then(() => subscription);  }

Так же нам нужно сформировать отправляемое уведомление

Вы можете манипулировать данными уведомления при помощи Post Message API

self.addEventListener('push', function (event) {    if (!(self.Notification && self.Notification.permission === 'granted')) {        return;    }    const sendNotification = body => {        const title = "Заголовок уведомления";        return self.registration.showNotification(title, {            body,        });    };    if (event.data) {        const message = event.data.text();        event.waitUntil(sendNotification(message));    }});

Этап 2 (PHP)

Необходим php версии 7+

Далее в файле subscribeUserToPushNotifications на который мы сделали запрос с фронта при получении разрешения на подписку, мы формируем данные пользователя

subscribeUserToPushNotifications.php

<?php $subscription = $_POST;if (!isset($subscription['endpoint'])) {    echo 'Error: not a subscription';    return;}// save subscription from => $subscription 

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

Непосредственно сама отправка происходит следующим образом

Достаем юзера с места его сохранения, и далее создаем объект подписчика:

pushNotificationToClient.php

<?php use Minishlink\WebPush\WebPush;use Minishlink\WebPush\Subscription;$subscription = Subscription::create($subscriptionData);

Далее формируем VAPID для авторизации:

pushNotificationToClient.php

<?php $auth = array(    'VAPID' => array(        'subject' => 'https://your-project-domain.com',        'publicKey' => file_get_contents(__DIR__ . '/your_project/keys/public_key.txt'),        'privateKey' => file_get_contents(__DIR__ . '/your_project/keys/private_key.txt'),     ));

После того как сформировали нужные данные, создаем новый объект WebPush:

pushNotificationToClient.php

<?php$webPush = new WebPush($auth);

Ура! Наконец мы можем отправить запрос на отправку Push уведомления

<?php$report = $webPush->sendOneNotification(  $subscription,  "Тело пуш уведомления, оно поступило в тело sw.js");

Важное замечание

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

$webPush->queueNotification

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

  1. О технологии push

  2. О WebPush от хабровчанина

  3. Библиотека WebPush

  4. Пример использования от разработчика библиотеки

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru