PWA - progressive web app
Весь код прдставленный ниже лишь для изучения. Он весьма плохого качества, изобилует антипаттернами, повторением, устаревшими стандартами (отсутствием синтаксического сахара). Иными словами, он лишь для изучения PWA и методов для взаимодействия с новыми технологиями. Большинство примеров честным образом стырено с udemy. Все примеры доступны на github
Полезные ссылки
- Про воркеры и shared воркеры
- udemy usefull links
- manifest support
- google description about manifest
- chrome debug PWA
- Media streams
- getUserMedia
- Geolocation
- Workbox github
- Workbox стратегии кеширования
- SW preacache webpack plugin
- React PWA
- vue.js PWA
- Angular PWA
Progressive web app :pwa:
PWA vs native app
Для PWA доступно множество фич для работы с переферией, однако, native приложений ~87% pwa ~13%. Такое соотношение обусловлено относительной новизной технологии, а также неполной поддержкой браузеров.
App manifest
App manifest - корневой файл, описывающий мета информацию о работе нашего PWA.
Файл аходится в корне проекта manifest.json
Манифест
{
"name": "Имя нашего PWA",
"short_name": "Короткое имя для иконки приложения",
"start_url": "Страница загружаемая при открытии PWA, например index.html",
"scope": "Страницы включаемые в приложение, например .",
"display": "standalone - без url бара, как приложение выглядит, standalone - default занчение",
"background_color": "hex значение для отображения цвета при загрузке приложения",
"theme_color": "hex цвет баров",
"description": "...",
"dir": "read direction ltr/rtl",
"lang": "язык, например: en-US",
"orientation": "ориентация экрана: portrait-primary",
"icons": [
{
"src": "...",
"type": "image/png",
"sizes": "48x48/96x96"
}
],
"related_applications": [
{
"platform": "play",
"url": "...",
"id": ",,"
}
]
}
“display” свойство
- standalone - как обычное приложение
- fullscreen - без баров
- minimal-ui
- browser - очевидно нет?*
Добавляем в index.html
<link rel="manifest" href="/manifest.json">
Browser
Safari
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="PWAGram">
<link rel="apple-touch-icon" href="/src/images/icons/..." sizes="144x144">
Internet explorer
Фиг знает зачем вообще что-то поддерживать в ie, но на удивление даже в нем есть какие-то надстройки.
Иконка
<meta name="msapplication-TileImage" content="/src/images/icons/some-img.png">
Цвет иконки
<meta name="msapplication-TileColor" content="#fff">
Цвет темы
<meta name="theme-color" content="#3f51b5">
Service worker
Worker events
Service worker живет бекграунде.
- fetch - page related javascript (http requst) axios и похожее не работает :(
- push notifications
- взаимодействие с уведомлениям
- Синхронизация в бекграунде
- Service worker lifecycle
Service worker lifecycle
Регистрация сервис воркера происходит 1 раз, и в случае если он изменился
- Install event
- Activate event
Регистрация сервис воркера + проверка что он доступен
navigator?.serviceWorker.register("/sw.js").then(function () {
console.log("Service worker registered!");
});
register принимает объект, позволяющий выделить скоуп для страниц на которых он будет применен
navigator?.serviceWorker.register("/sw.js", { scope: '/help/' }).then(function () {
console.log("Service worker registered!");
});
Событие installed
Тут и далее self - обращение к сервис воркеру
self.addEventListener("install", (event) => {
console.log("[Service Worker] installed", event);
});
Событие активации
self.addEventListener("activate", (event) => {
console.log("[Service Worker] activated", event);
return self.clients.claim();
});
Можно и не возвращать клиентов, однако рекомендуется возвращать т.к. мб ошибки.
События не в lifecycle
fetch
self.addEventListener("fetch", (event) => {
console.log("[Service Worker] fetched", event);
event.respondWith(null);
});
fetch - типичный прокси, можно переопределять ответы (увы, не аксиос и XMLHttpRequest, или не увы, пока непонятно)
self.addEventListener("fetch", (event) => {
console.log("[Service Worker] fetched", event);
event.respondWith(fetch(event.request));
});
Установка PWA на устройство
Регистрируем специальный ивент, который позволяет перехватить попап для установки
window.addEventListener("beforeinstallprompt", function (event) { console.log("beforeinstalprompt fired"); event.preventDefault(); defaultPrompt = event; // default prompt в глобальном скоупе. return false; });
Далее можно обработать defaultPrompt по нужному тригеру
function openCreatePostModal() {
createPostArea.style.display = "block";
if (defaultPrompt) {
defaultPrompt.prompt();
defaultPrompt.userChoice.then((choiceResult) => {
console.log(choiceResult);
if (choiceResult.outcome === "dismissed") {
console.log("User canceled installation");
} else {
console.log("User accepted installation");
}
defaultPrompt = null;
});
}
}
shareImageButton.addEventListener("click", openCreatePostModal);
Программно удалить все зарегистрированные воркеры
if ("serviceWorker" in navigator) { navigator.serviceWorker.getRegistrations().then((registrations) => { registrations.forEach((r) => { r.unregister(); }); }); }
Как отписать сервис воркер
navigator.serviceWorker.getRegistrations().then(function(registrations) {
for (let registration of registrations) {
registration.unregister()
}})
Cache для работы оффлайн (статика)
Достаточно тривиально. кешируем запросы, перехватываем обращение к статике и применяем результат из кеша
Реализация кеша
self.addEventListener("install", function (event) {
console.log("[Service Worker] Installing Service Worker ...", event);
// Дожидаемся асинхронного выполнения кеширования
event.waitUntil(
caches.open("static").then(function (cache) {
console.log("[Servoce Worker] Pre caching");
cache.add("/src/js/app.js"); //Кеширование
cache.add("/");
})
);
});
self.addEventListener("fetch", function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
if (!response) {
return fetch(event.request);
}
return response;
})
);
});
Что кешировать не нужно?
- Полифилы, если браузер старый он и в сервис воркеры не умеет, следовательно и смысла от такого кеширования нет.
- Иконки (pwa). Т.к. они уже внутри нашего воркера.
- JSON ответ с сервера (кешируем только статику, для ответов с сервера используется другой подход - локальное хранилище + middleware)
- POST запросы, как и другие запросы на мутацию. (пожалуй это очевидно, их на сервере обычно не кешируют, за исключением каких-то уж совсем кастомных rpc)
Динамическое кеширование
внутри service-worker скрипта
self.addEventListener("fetch", function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
console.log(event.request);
if (!response) {
return fetch(event.request).then((res) => {
// Динамически кешируем все загруженные ресурсы
return caches.open("dynamic").then((cache) => {
cache.put(event.request.url, res.clone());
return res;
});
});
}
return response;
})
);
});
Версионирование кеша
Для версионирования необходимо очистить предыдущий кеш и создать новый, с новым именем
caches.open("static-v2").then(function (cache) { ... }
Удаление происходит в хуке активации
self.addEventListener("activate", function (event) {
console.log("[Service Worker] Activating Service Worker ....", event);
event.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(
keyList.map((key) => {
if (key !== "static-v2" && key !== "dynamic") {
console.log("[Service Wrker] Removing old cache", key);
return caches.delete(key);
}
})
);
})
);
return self.clients.claim();
});
Пример рабочего кеширования (ссылки вставить свои) более линейным синтаксисом
Пример
const STATIC_CACHE_NAME = "static-v-3";
const DYNAMIC_CACHE_NAME = "dynamic-v-3";
self.addEventListener("install", (event) => {
const preCache = async () => {
const cache = await caches.open(STATIC_CACHE_NAME);
return cache.addAll([
"/",
"/index.html",
"/src/js/material.min.js",
"/src/js/main.js",
"/src/css/app.css",
"/src/css/main.css",
"https://fonts.googleapis.com/css?family=Roboto:400,700",
"https://fonts.googleapis.com/icon?family=Material+Icons",
"https://cdnjs.cloudflare.com/ajax/libs/material-design-lite/1.3.0/material.indigo-pink.min.css",
]);
};
event.waitUntil(preCache());
});
self.addEventListener("activate", (event) => {
const clearCache = async () => {
const cacheKeys = await caches.keys();
cacheKeys
.filter((k) => k !== STATIC_CACHE_NAME && k !== DYNAMIC_CACHE_NAME)
.forEach(async (k) => {
await caches.delete(k);
});
};
event.waintUntil(clearCache());
return event.clients.claim();
});
self.addEventListener("fetch", (event) => {
const tryExtractCache = async () => {
const cacheRspns = await caches.match(event.request);
if (cacheRspns) {
return cacheRspns;
}
const rspns = await fetch(event.request);
const cache = await caches.open(DYNAMIC_CACHE_NAME);
cache.put(event.request.url, rspns.clone());
return rspns;
};
event.respondWith(tryExtractCache());
});
Web push notifications
Пуш уведомления монжно использовать и без PWA, это очень мощная особенность современных браузеров. Push notifications позволяют отправлять уведомления даже если пользователь выключил браузер, работая по принципу обычных уведомлений из телефонных приложений. При включенном браузере разработчик может управлять полученными уведомлениями с помощью notification API, визуализируя их со специфическими стилями. Такие уведомления позволяют привлечь пользователя, сохраняя активный трафик веб ресурса.
Так образом web push notification позволяет максимально приблизить опыт использования PWA к нативным решениям под android, ios и другие платформы. Web push notification работают внутри система, а не внутри браузера или темболее скоупа html
Поддержка браузерами.
На момент написания данной статьи PWA поддерживаютсяпочти всеми браузерами (за исклюдчением IE)
Использование
Перед использованием уведомлений необходимо запросить права доступа.
Имлементация
<button class="enable-notifications">ENABLE NOTIFICATIONS</button>
var enableNotificationsButtons = document.querySelectorAll(
".enable-notifications"
);
function askForNotificationPermission() {
Notification.requestPermission((result) => {
console.log('[line 30][app.js] 🚀 result: ', result);
if (result !== 'granted') {
console.log('No notification permission granted!')
}
});
}
if ("Notification" in window) {
enableNotificationsButtons.forEach((n) => {
n.style.display = "inline-block";
n.addEventListener('click', askForNotificationPermission);
});
}
После запроса мы можем отправить уведомление, создав класс Notification, данный класс принимает 1 аргументом констурктора текст, 2 специальный js объект с опциями
function displayConfirmNotification() {
const options = {
body: "You sucessfully subscribe to our Notification service!",
};
new Notification("Successfully subscribed!", options);
}
Обновим функцию показа запроса permissions
function askForNotificationPermission() {
Notification.requestPermission((result) => {
console.log("[line 30][app.js] 🚀 result: ", result);
if (result !== "granted") {
console.log("No notification permission granted!");
return;
}
// TODO: SHOW NOTIFICATION
displayConfirmNotification();
});
}
Интеграция с сервис воркером
Сервис воркер имеет свой интерфейс для взаимодействия с уведомлениямми - showNotification. Переделаем предыдущую функцию для отображения уведомления: Не забудь очистить кеш и перезагрузить страницу!
function displayConfirmNotification() {
if ("serviceWorker" in navigator) {
const options = {
body: "You sucessfully subscribe to our Notification service!",
};
navigator.serviceWorker.ready.then((swreg) => {
swreg.showNotification("Successfully subscribed! (from SW!)", options);
});
}
}
Options
Пример настроек wep push notification
const options = {
body: "You sucessfully subscribe to our Notification service!",
icon: "/src/images/icons/app-icon-96x96.png",
iamge: "/src/images/sf-boat.jpg",
dir: "ltr",
lang: "en-UIS", // BCP 47
vibrate: [100, 50, 200], // vibration ms, not every device support it
badge: "/src/images/icons/app-icon-96x96.png", // Android, andoid will automatically mask colorfull pics
tag: "confirm-notification", // Something like ID (for stack notification with same tag)
renotify: true, // Even use same tag, phone will vibrate
actions: [
{
action: "confirm",
title: "Okay",
icon: "/src/images/icons/app-icon-96x96.png",
},
{
action: "cancel",
title: "Oh no..no-no",
icon: "/src/images/icons/app-icon-96x96.png",
},
],
};
body
- Текст в теле уведомления
icon
- иконка, отображается как на мобильных устройствах, так и на пк
image
- превью изображения в теле уведомления, будет показано в ’шторке’ уведомления на телефоне
vibrate
- список из number. Время вибрации для мобильных устройств.
badge
- маленькая иконка в свернутой шторке уведомлений, работает на android. Умеет автоматически преобразовывать цветные изображения в монохромные.
tag
- сущность для группировки однотипных уведомлений, в случае если не хотим беспокоить пользователя указываем одинаковый ID. В таком случае уведомления будут сгруппированы, и не будут беспокоить пользователя.
renotify
- если включен тег, мы можем привлечь внимание пользователя легкой вибрацией, не показывая снова уведомления.
Если оба пунка выключены, каждое уведомление будет отображаться как новое.
actions
- список из событий, одна из самых важных частей уведомления, необходимая для интерактивного взаимодействия. Принимает массив из объектов, где
action - событие пользователя (cancel/confirm)
title - название события, будет отображено над кликом
icon - иконка рядом с событием.
Отсллеживание событий web push уведомлений
Для отслеживания событий необходимо подписаться на соответствующую функцию обратного вызова: sw.js
self.addEventListener("notificationclick", (event) => {
const notificaiton = event.notification;
const action = event.action;
console.debug("[line 215][sw.js<2>] 🚀 notificaiton: ", notificaiton);
if (action === "confirm") {
console.log("Confirm was choosen");
notification.close();
return;
}
console.log(action);
});
Однако, такой подход обеспечит отслеживание предусмотренных событий. Если пользователь просто закроет уведомление необходимо подписаться на отдельное событие
self.addEventListener('notificationclose', (event) => {
console.log('Notification was closed', event)
})
Отправка web push уведомлений с сервера
При отправке уведомлений с сервера необходимо обеспечить 100% гарантию того что уведомления отправляет именно наш сервер. Реализовать подобный функционал средствами javascript невозможно (т.к. он открыт, и кто угодно может видеть его начинку). Однако, существуют решения. Например VAPID, который позволяеют достичшь подобной гарантии, избегая компроментации сообщений.
Интересный факт
Payload в webpush notification ограничен 4 килобайтами.
Бекенд.
Чтобы вдоволь поиграться с возможностью web push notificaiton нам нужен бекенд сервер. Можно использовать firebase с функциями, но не так давно они обновили билинговый план, теперь нельзя использовать firebase для изучения. Поэтому поднимем бекенд сервер локально с помощью дедушки express, а также установим библиотеку web-push. Для тех кто хочет просто что-нибудь скопировать/скачать
yarn add express body-parser web-push
Или для олдов
npm i --save express body-parser web-push
Сгенерируемvapid ключи ./node_modules/.bin/web-push generate-vapid-keys
Ну или по православному, package.json
package.json
{
"name": "server",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"vapid:generate": "web-push generate-vapid-keys"
},
"license": "MIT",
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"web-push": "^3.4.5"
}
}
index.js
//Express
const express = require("express");
//web-push
const webpush = require("web-push");
//body-parser
const bodyParser = require("body-parser");
//path
const path = require("path");
// cors
const cors = require("cors");
//using express
const app = express();
// app.use(cors());
//using bodyparser
app.use(function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
next();
});
app.use(bodyParser.json());
app.use(
bodyParser.urlencoded({
extended: true,
})
);
//storing the keys in variables
const publicVapidKey =
"BJxgti75ow0Q96X5fGMIz5PAwdR4a_eGg9KbrpTo8qBaacix5UEMBX0S2amT3HweXWsClr5tsV3EOMYWElIlhOc";
const privateVapidKey = "v0mrf6LhO8U2BzmOkqYa5Te8dPr7ovjOuBU_nddfocU";
//setting vapid keys details
webpush.setVapidDetails(
"mailto:somerealemal@google.com",
publicVapidKey,
privateVapidKey
);
const memcacheSubscriptionStore = [];
//subscribe route
app.post("/subscribe", (req, res) => {
//get push subscription object from the request
const subscription = req.body;
console.debug("[line 33][index.js] 🚀 req: ", req.body);
//send status 201 for the request
res.status(201).json({});
//create paylod: specified the detals of the push notification
const payload = JSON.stringify({ title: "Hello from node.js backend" });
setTimeout(() => {
webpush
.sendNotification(subscription, JSON.stringify({title: "TIMEOUT MSG", body: "Okay, it works perfect now"}))
.catch((err) => console.log(err));
}, 5000);
});
const port = 3010;
app.listen(port, () => {
console.log(`server started on ${port}`);
});
Фронтенд
Для начала функция вызова показа уведомлений (реализовывали ранее)
Функция показа уведомлений
function displayConfirmNotification(title) {
if ("serviceWorker" in navigator) {
const options = {
body: title || "You sucessfully subscribed! But title not provided.",
icon: "/src/images/icons/app-icon-96x96.png",
iamge: "/src/images/sf-boat.jpg",
dir: "ltr",
lang: "en-UIS", // BCP 47
vibrate: [100, 50, 200], // vibration ms, not every device support it
badge: "/src/images/icons/app-icon-96x96.png", // Android, andoid will automatically mask colorfull pics
tag: "confirm-notification", // Something like ID (for stack notification with same tag)
renotify: true, // Even use same tag, phone will vibrate
actions: [
{
action: "confirm",
title: "Okay",
icon: "/src/images/icons/app-icon-96x96.png",
},
{
action: "cancel",
title: "Oh no..no-no",
icon: "/src/images/icons/app-icon-96x96.png",
},
],
};
navigator.serviceWorker.ready.then((swreg) => {
swreg.showNotification("Successfully subscribed! (from SW!)", options);
});
}
}
Теперь реализуем конфигуратор подписки на обновления
Детали
const publicVapidKey =
"BJxgti75ow0Q96X5fGMIz5PAwdR4a_eGg9KbrpTo8qBaacix5UEMBX0S2amT3HweXWsClr5tsV3EOMYWElIlhOc";
function configurePushSub() {
if (!("serviceWorker" in navigator)) {
return;
}
let reg;
navigator.serviceWorker.ready
.then((swreg) => {
reg = swreg;
return swreg.pushManager.getSubscription();
})
.then((sub) => {
console.debug("[line 74][app.js] 🚀 sub: ", sub);
// if (!sub) {
// Create a new subscription
return reg.pushManager.subscribe({
userVisibleOnly: true, // Only visible for current user
applicationServerKey: urlBase64ToUint8Array(publicVapidKey), // Our key for determine sender
});
// }
// We have a subscription
})
.then((newSub) => {
console.debug("[line 85][app.js] 🚀 newSub: ", newSub);
return fetch("http://localhost:3010/subscribe", {
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(newSub),
});
})
.then((res) => {
return res.clone().json();
})
.then((res) => {
console.debug("[line 101][app.js] 🚀 res: ", res);
displayConfirmNotification(res.title);
})
.catch((err) => {
console.warn("[line 101][app.js] 🚀 err: ", err);
});
}
Детали
function askForNotificationPermission() {
Notification.requestPermission((result) => {
console.log("[line 30][app.js] 🚀 result: ", result);
if (result !== "granted") {
console.log("No notification permission granted!");
return;
}
// TODO: SHOW NOTIFICATION
// displayConfirmNotification();
configurePushSub();
});
}
Обработка события
Реализуем обработку события, по клику на уведомления откроем существующий таб (сделаем видимым через focus), либо создадим новый. sw.js
Обработчик событий клика на уведомление
self.addEventListener("notificationclick", (event) => {
const notificaiton = event.notification;
const action = event.action;
console.debug("[line 215][sw.js<2>] 🚀 notificaiton: ", notificaiton);
if (action === "confirm") {
console.log("Confirm was choosen");
notification.close();
return;
}
event.waitUntil(
// All winwows
clients
.matchAll()
.then((clis) => {
const client = clis.find((cl) => cl.visibilityState === "visible");
if (client) {
client.navigate("http://localhost:8083");
client.focus();
return;
}
clients.openWindow("http://localhost:8083");
})
.then(() => {
notificaiton.close();
})
);
console.log(action);
});
Native mobile device features
Захват камеры
<video id="player" autoplay></video>
<canvas id="canvas" width="320px" height="240px"></canvas>
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--colored" id="capture-btn">Capture
</button>
Для доступа к медиа устройству (аудио + видео, видео вкючает в себя работу с фото) используется функциналmediaDevices Опишем полифил для работы с mediaDevices
mediaDevices полифил
function initalizeMedia() {
if (!('mediaDevices' in navigator)) {
navigator.mediaDevices = {};
}
if (!('getUserMedia' in navigator.mediaDevices)) {
navigator.mediaDevices.getUserMedia = (constraints) => {
const getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia is not implemented'));
}
return new Promise((resolve, reject) => {
return getUserMedia.call(navigator, constraints, resolve, reject);
});
}
}
}
Как работает этот полифил? Весьма просто, если mediaDevices неподдерживаются мы создаем пустй объект, затем проверяем есть ли webkit или moz реализация, если есть устанавливаем ее для getUserMedia функции. В противном случае создаем промис с негативным результатом (райзим ошибку) initalizeMedia
можно вызвать в любом удобном месте.
При открытии страницы браузер должен запросить в 1 раз разрешение на использованием камеры. После этого должно появится изображение.
Повесим экшен на кнопку, кудаж без него
Click event listener
captureButton.addEventListener("click", (event) => {
canvasElement.style.display = "block";
videoPlayer.style.display = "none";
captureButton.style.display = "none";
const context = canvasElement.getContext("2d");
context.drawImage(
videoPlayer,
0,
0,
canvasElement.width,
videoPlayer.videoHeight / (videoPlayer.videoWidth / canvas.width)
);
videoPlayer.srcObject.getVideoTracks().forEach((track) => {
track.stop();
});
});
Тут из интересного videoPlayer.videoHeight / (videoPlayer.videoWidth / canvas.width)
, по факту это просто вычисление соотношения сторон.
Для конвертации нашей картинке нам необходима функция преобразования URL в блоб (если по какой-то причине вам не досталась ваша версия copilot то пжалуста):
Для тех кому не достался его маленький copilot
function dataURItoBlob(dataURI) {
// convert base64/URLEncoded data component to raw binary data held in a string
var byteString;
if (dataURI.split(',')[0].indexOf('base64') >= 0)
byteString = atob(dataURI.split(',')[1]);
else
byteString = unescape(dataURI.split(',')[1]);
// separate out the mime component
var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
// write the bytes of the string to a typed array
var ia = new Uint8Array(byteString.length);
for (var i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ia], {type:mimeString});
}
теперь можно преобразовать наш канвас в блоб picture = dataURItoBlob(canvasElement.toDataURL());
Для того чтобы остановить запись с камеры:
if (videoPlayer.srcObject) {
videoPlayer.srcObject.getVideoTracks().forEach(function (track) {
track.stop();
});
}
Также нам понадобиться сервер, если у вас нет возможности использовать firebase для тестов то welcome вмаленький экспресс сервер написанный мной на коленке.
Получение геолокации пользователя
Скрипт получения геолокации
function initializeLocation() {
if (!("geolocation" in navigator)) {
locationBtn.style.display = "none";
// Сервис геологкации недоступен
}
navigator.geolocation.getCurrentPosition(
(position) => {
console.debug("[line 36][feed.js] 🚀 position: ", position);
restoreLocationView();
fetechedLocation = position.coords;
locationInout.value = JSON.stringify(fetechedLocation);
locationInput.classList.add("is-focused");
},
(err) => {
console.log(err);
restoreLocationView();
fetchedLocation = null;
alert("Couldn't fetch location, please enter manually");
},
{
timeout: 7000, // 7 секунд на нахождение позиции.
}
);
}
Что представляет из себя функцтя getCurrentPosition
1 аргумент - функция обратного вызова с упешно полученный геопозицией 2 аргумент - колбек для перехвата ошибки 3 - объект конфигурации, где timeout - количество секунд перед ошибкой.
Background синхронизация
Background необходима для того, чтобы данные можно было сложнее синхронизировать чтобы можно было менять контент без реального доступа к сети. Данные меняются локально, сохраняясь в промежуточный кеш. После подключения к сети срабатывает специальный метод внутри сервис воркера - sync
, внутри которого можно передать данные из промежуточного хранилища на наш сервер.
Кроме того, помнишь проблему с тем что юзер может отправить запрос и сразу уйти со страницы? Данные будут неконсистентны. Пользователь считает что все нормально, на самом же деле запрос не успел выполниться (большой ttfb, медленный интернет, большое тело запроса и тд.). Фоновая синхронизация решает эту проблему. Т.к. она работает с закрытой вкладкой.
За синхронизацию данных отвечаетSyncManager. Сооствественно любая работа с функицоналом должна начинаться с проверки поддержки браузером: if ("serviceWorker" in navigator && "syncManager" in window) {
К несчастью, на момент написания статьи, большинство браузеров не поддерживают данный функционал
Ссылки
Реализация фоновой синхронизации при выходе из offline
Реализация представляет собой обычный прокси. Все запросы, вместо отправки на сервер, сохраняются в промежуточное хранилище. В нашем случае промежуточное хранилище - indexed db.
После чего можно синхронизировать хранилище с сервером (или firebase)
Отправка запроса из бизнес логики
form.addEventListener("submit", function (event) {
event.preventDefault();
event.stopPropagation();
if (titleInput.value.trim() === "" || locationInput.value.trim() === "") {
alert("Please enter valid data!");
return;
}
closeCreatePostModal();
if ("serviceWorker" in navigator && "SyncManager" in window) {
navigator.serviceWorker.ready.then((sw) => {
const post = {
id: new Date().toISOString(),
title: titleInput.value,
location: locationInput.value,
};
// Store indexdb
writeData("sync-posts", post)
.then(() => {
return sw.sync.register("sync-posts");
})
.then(() => {
const snackbar = document.querySelector("#confirmation-toast");
const data = { message: "Your Post was save for syncing!" };
snackbar.MaterialSnackbar.showSnackbar(data);
});
});
return;
}
// Service worker unsupported block
sendData();
});
Синронизация происходит в 2 случаях: 1 - при восстановлении соединения с интернетом, 2 - периодическая синхронизация.
Синхронизация при восстановлении соединения
self.addEventListener("sync", (event) => {
console.log("[Service Worker] Background syncing", event);
if (event.tag === "sync-posts") {
console.log("[Service Worker] Syncing new posts");
event.waitUntil(
readAllData("sync-posts").then((data) => {
data.forEach((d) => {
fetch("https://react-23388.firebaseio.com/posts.json", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
id: d.id,
title: d.title,
location: d.location,
}),
})
.then((res) => {
console.log("Sent data", res);
if (res.ok) {
deleteItemFromData("sync-posts", d.id);
}
})
.catch((err) => {
console.log("Error while sending data.");
});
});
})
);
}
});
Периодическая PWA синхронизация
На момент напсания статьи не поддерживается ни в 1 из браузеров Впрочем, достичь ее можно с помощью dedicated worker и очереди (например взятой из rxjs)
Workbox
Инструмент для упращения управления service worker.Подробнее про workbox можно посмотреть тут
Install
2 версия
npm install --save-dev workbox-cli@^2
Добавим скрипт для генерации. package.json
"scripts": {
"start": "http-server -c-1 -p 8083",
"generate-sw": "workbox generate:sw"
},
Запускаем. Следуем инструкции. В процессе вводим имя для service worker, я назвал service-worker.js
, после установки регестрируем именно этот файл
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/service-worker.js")
.then(function () {
console.log("Service worker registered!");
})
.catch(function (err) {
console.log(err);
});
}
Однако, данные метод закешировал только то что хранится в статике, игнорируя динамический кеш. Это легко проверить, достаточно включить offline режим и перезагрузить страницу. Кроме того, workbox закешировал и часть статических файлов, которые не нуждались в кешировании.
Чтобы исправить это настроим паттерны в workbox-cli-config.js Например, вместого того чтобы кешировать все изображения будем кешировать только те, что находятся в images директории. Также, предположим что не хотим кешировать файлы из help директории
module.exports = {
globDirectory: "public/",
globPatterns: ["**/*.{ico,html,json,css,js}", "src/images/*.{jpg,png}"],
swDest: "public/service-worker.js",
globIgnores: ["../workbox-cli-config.js", "help/**"],
};
Вместо генерации service-worker целиком можно переиспользовать базовый файл. Для начала удалим сгенерированный ранее service-worker.js и создадим sw-base.js:
importScripts("workbox-sw.prod.v2.1.3.js");
const workboxSW = new self.WorkboxSW();
workboxSW.precache([]);
Пустой прекеш будет заменне реальными файлами, в скрипт json заменим generate-sw
"scripts": {
"start": "http-server -c-1 -p 8083",
"generate-sw": "workbox inject:manifest"
},
Обновим workbox-cli-config
{
globDirectory: "public/",
globPatterns: ["**/*.{ico,html,json,css,js}", "src/images/*.{jpg,png}"],
swSrc: "public/sw-base.js",
swDest: "public/service-worker.js",
globIgnores: ["../workbox-cli-config.js", "help/**"],
}
Ну и синхронизируем yarn generate-manifest
Динамическое кеширование с помощью workbox
Сейчас workbox кеширует только статику, оно конечно прекрасно, но если зайти в оффлайн то половина шрифтов и стилей отсутствует. Любого линуксоида оно конечно устроит, но давайте вернем все как было
sw-base.js
const workboxSW = new self.WorkboxSW();
workboxSW.router.registerRoute(/.*(?:googleapis|gstatic)\.com.*$/,
workboxSW.strategies.staleWhileRevalidate({
cacheName: 'google-fonts'
}));
workboxSW.precache([]);
Из приятного, регистратор роута принимает регекспы.
staleWhileRevalidate
cache then network. Иными словами, если нет в кеше данных то делаем запрос, после выполнения fetch кешируем
Также роут моет принимать кастомную имплементацию в виде колбека
Например
workboxSW.router.registerRoute("http://localhost:3010/posts", (args) => {
return fetch(args.event.request).then(function (res) {
var clonedRes = res.clone();
clearAllData("posts")
.then(function () {
return clonedRes.json();
})
.then(function (data) {
for (var key in data) {
writeData("posts", data[key]);
}
});
return res;
});
});
Кроме того, первый аргумент также может быть колбеком, в котором можно имплементить более сложную логику, например кешировать только text/html запросы
#+START_SPOILER Кеширование только text/html >
workboxSW.router.registerRoute(
(routeData) => {
return routeData.event.request.headers.get("accept").includes("text/html");
},
(args) => {
return caches.match(args.event.request).then(function (response) {
if (response) {
return response;
} else {
return fetch(args.event.request)
.then(function (res) {
return caches.open("dynamic").then(function (cache) {
// trimCache(CACHE_DYNAMIC_NAME, 3);
cache.put(args.event.request.url, res.clone());
return res;
});
})
.catch(function (err) {
return caches.match("/offline.html").then(function (cache) {
if (
args.event.request.headers.get("accept").includes("text/html")
) {
return cache.match("/offline.html");
}
});
});
}
});
}
);
#+CLOSE_SPOILER
Еще немного про workbox
Хорошой новостью является то, что мы можем писать обычный код нашем base worker после workboxSW.precache
Все примеры доступны на github