PWA (Progressive Web App) — это веб-приложение, которое поддерживает расширенный функционал и чуточку лучше интегрируется с устройством пользователя.
Такие приложения могут присылать пуш-уведомления, работать оффлайн, кэшировать данные, использовать API ОС и работать с файловой системой.
Прогрессивные веб-приложения как концепция начали формироваться в начале 2000-х годов, когда Microsoft создала технологию HTA (HTML Application), а затем в 2007 году Apple предложила использовать веб-приложения для первого iPhone. Однако эти ранние попытки не получили широкого распространения из-за ограниченного пользовательского опыта и появления App Store, который сместил фокус на нативные приложения.
Тем не менее, в современном мире прогрессивные веб-приложения очень популярны, они активно используются в приложениях из различных сфер, разные компании стремятся переделать существующие веб-приложения для того чтобы пользователь мог получить опыт использования полноценного приложения при минимальных затратах на разработку.
Как работает PWA?
Принцип работы PWA довольно прост - когда пользователь впервые заходит на сайт с поддержкой прогрессивных веб-приложений, браузер автоматически загружает и регистрирует специальный скрипт под названием Service Worker. Этот скрипт берет на себя управление кэшированием всех ресурсов сайта, что позволяет приложению работать даже без подключения к интернету.
После первого посещения пользователю предлагается добавить сайт на главный экран своего устройства - при согласии там появляется полноценная иконка приложения, как у обычных нативных программ. При последующих запусках через этот ярлык PWA будет открываться в полноэкранном режиме без привычной адресной строки браузера, создавая полную иллюзию работы с обычным приложением.
Важной особенностью PWA является возможность работы офлайн - если интернет-соединение становится недоступным, приложение продолжает функционировать, используя ранее закэшированные данные. Кроме того, даже когда PWA не запущено, оно способно отправлять пользователю push-уведомления, информируя о важных событиях или обновлениях.
Архитектура и технологии
Прогрессивные веб-приложения опираются на набор современных веб-стандартов и архитектурных подходов, обеспечивающих нативный пользовательский опыт при сохранении гибкости и доступности традиционного веба.
Вот три столпа, на которых основывается каждый PWA:
- Web App Manifest — файл, описывающий метаданные приложения, необходимые для установки PWA на устройство пользователя;
- Service Worker — файл, работающий в фоновом режиме и обеспечивающий такую функциональность как кэширование, офлайн-работа и push-уведомления;
- Cache API — интерфейс для программного управления кэшем браузера, позволяющий сохранять ресурсы для офлайн-доступа;
Web App Manifest
Web App Manifest — это JSON-файл, в котором расположена информация, которая понадобится ОСи устройства для установки PWA.
Давайте рассмотрим следующий пример файла manifest.json:
{
"name": "My Progressive Web App",
"short_name": "PWA",
"start_url": "/",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#fff",
"icons": [
{
"src": "/images/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Этот файл описывает следующие детали:
name
— название приложения;short_name
— краткое название приложения;start_url
— URL приложения, который будет открыт при запуске приложения;display
— режим отображения приложения, может быть одним из следующих значений:fullscreen
— приложение занимает весь экран, включая скрытие статус-бара, навигационных кнопок и адресной строки (не доступен на iOS/iPadOS, однако можно использовать Fullscreen API);standalone
— приложение выглядит как нативное: нет адресной строки браузера, но остаются системные элементы ОС (статус-бар, кнопки навигации);minimal-ui
— похож на standalone, но добавляет минимум навигационных элементов браузера (например, кнопку “назад”);
background_color
— цвет фона приложения;theme_color
— цвет основного окна приложения;icons
— массив иконок приложения, каждая из которых содержит следующие детали:src
— URL иконки;sizes
— размеры иконки в виде строки, где каждый символ представляет собой размер в пикселях;type
— тип иконки (по умолчанию —image/png
).
Service Worker
Service Worker — это файл, работающий в фоновом режиме и обеспечивающий такую функциональность как кэширование, офлайн-работа и push-уведомления. Данный файл содержит Javascript-код, который выполняется в асинхронном потоке на стороне клиента.
Для того чтобы зарегистрировать Service Worker, необходимо использовать код, похожий на следующий:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/serviceworker.js');
}
В данном сниппете мы проверяем доступен ли функционал serviceWorker
в браузере и в случае доступности API регистрируем новый воркер с указанием URL файла.
По умолчанию у клиента может быть только один активный Service Worker на странице.
Жизненный цикл
Жизненный цикл Service Worker состоит из следующих этапов:
Этап | Описание |
---|---|
Регистрация | Браузер регистрирует скрипт service worker, определяет его область действия и подготавливает к использованию |
Установка | Service worker загружается, кэширует необходимые ресурсы и готовится к активации |
Активация | Service worker берет управление на себя, очищает старые кэши и начинает обрабатывать события |
Обновление | Новая версия загружается и устанавливается в фоновом режиме, затем активируется при возможности |
По умолчанию после регистрации Service Worker сразу же устанавливается в фоновом режиме. Для установки Service Worker не нужно разрешение пользователя. Также, стоит упомянуть, что Service Worker не активируется сразу же после установки, для его активации нужно перезагрузить страницу.
Файл Service Worker
По умолчанию воркер инициализируется в отдельном файле с расширением .js
.
В данном файле мы можем использовать ключевое слово self
для доступа к API Service Worker. У самого Service Worker API отличается от API, который есть в Window
.
В Service Worker присутствуют следующие глобальные объекты:
API | Описание |
---|---|
clients | API для работы с активными вкладками и окнами под контролем Service Worker |
caches | API для программного управления кэшем браузера |
fetch | API для выполнения сетевых запросов |
skipWaiting | Метод для немедленной активации нового Service Worker |
registration | Ссылка на объект регистрации текущего Service Worker |
serviceWorker | Ссылка на текущий экземпляр Service Worker |
importScripts | Функция для импорта внешних скриптов |
location | Информация о текущем URL Service Worker |
indexedDB | API для работы с IndexedDB |
webSocket | API для работы с WebSocket соединениями |
Внутри данного файла мы можем выполнять разные задачи: кэширование ресурсов и запросов, определение поведения при оффлайн-режиме, обработка push-уведомлений и т.д.
Например, мы можем закэшировать нужные нам ресурсы при установке Service Worker:
// Версия кэша, используется для обновления
const CACHE_VERSION = 'v1';
// Список ресурсов для кэширования
const CACHE_FILES = ['/', '/index.html', '/styles.css', '/app.js', '/images/logo.png'];
// Событие установки Service Worker
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION).then((cache) => {
// Кэшируем все необходимые файлы
return cache.addAll(CACHE_FILES);
})
);
});
Стоит уточнить, что мы должны ручками обрабатывать версионирование Service Worker, зачастую при обновлении Service Worker мы можем поменять ресурсы, которые мы кэшируем или логику, по которой мы кэшируем ресурсы. В таком случае нам нужно будет очищать старый кэш:
// Версия кэша, используется для обновления
const CACHE_VERSION = 'v1';
// Событие активации - очищаем старые версии кэша
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))
);
})
);
});
Область действия
Service Worker не может контролировать всё приложение без ограничений. Его «область действия» определяет, какие страницы или пути в вашем сайте он может обрабатывать. Область действия зависит от того, где лежит файл сервис-воркера.
Например, если Service Worker находится по адресу example.com/custom-path/sw.js
, он может управлять запросами для путей вроде example.com/custom-path/
или example.com/custom-path/demos/
, но не для example.com/another-path/
. То есть он работает только в пределах своей «папки» и ниже.
Кэширование
Одной из основных задач Service Worker является кэширование ресурсов сайта. Это позволяет ускорить загрузку страницы и уменьшить время ответа на запросы к серверу, к тому же, при желании Service Worker может помочь вам реализовать работу приложения без интернета.
Вот пример кэширования ресурсов с помощью Service Worker:
// Версия кэша, которая используется на текущий момент
const CACHE_STORE = 'v1';
// Перечисляем все ресурсы, которые нужно кэшировать
const urlsToCache = ['/', 'app.js', 'styles.css', 'logo.svg'];
// Добавляем слушатель событий на установку Service Worker
self.addEventListener('install', (event) => {
// Асинхронно кэшируем все нужные нам ресурсы
let cacheUrls = async () => {
const cache = await caches.open(CACHE_STORE);
return cache.addAll(urlsToCache);
};
// Говорим воркеру о том, что его установка будет закончена только после того, как весь кэш будет загружен
event.waitUntil(cacheUrls());
});
Проксирование
Проксирование также является одной из основных задач Service Worker. Оно позволяет Service Worker проксировать запросы к серверу и выполнять логику для кэширования ресурсов и обработки событий.
Мы можем создать прокси с помощью следующей логики:
// Добавляем слушатель событий, который будет обрабатывать каждый запрос сделанный клиентом
self.addEventListener('fetch', (event) => {
const response = fetch(event.request);
console.log('[Our Proxy] Response for: ', response);
return response;
});
Мы также можем кэшировать нужные нам запросы с помощью проксирования:
self.addEventListener('fetch', async (event) => {
event.respondWith(async () => {
// Сначала пробуем получить из кэша
const cacheMatch = await caches.match(event.request);
// Если ресурс найден в кэше, возвращаем его
if (cacheMatch) {
return cacheMatch;
}
const response = await fetch(event.request);
// Проверяем валидность ответа
if (!response || response.status !== 200) {
return response;
}
// Создаем копию ответа для кэширования
const responseToCache = response.clone();
// Сохраняем ответ в кэш
const cacheStore = caches.open(CACHE_VERSION);
cacheStore.put(event.request, responseToCache);
return response;
});
});
Обновление
Когда мы инициализировали первую версию нашего воркера, нам нужно позаботиться о механизме обновления. Мы можем использовать метод похожий на следующий:
async function checkUpdate() {
const registration = await navigator.serviceWorker.ready;
registration.addEventListener('updatefound', (event) => {
const newServiceWorker = registration.installing;
newServiceWorker.addEventListener('statechange', (event) => {
if (newServiceWorker.state == 'installed') {
// Service Worker установлен, но еще не активирован
}
});
});
}
После установки нового воркера он будет ожидать покуда текущая сессия завершится и активируется при возможности. В случае, если мы не делали изменений, которые несовместимы с прошлой версией, то мы можем обновить Service Worker без перезагрузки страницы:
self.addEventListener('install', (event) => {
// Форсирует текущий Service Worker
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
// Когда новый Service Worker активируется,
// мы будем ожидать пока все клиенты переведутся
// на новый Service Worker
event.waitUntil(clients.claim());
});
Стратегии работы
По умолчанию есть несколько общепринятых стратегий работы с Service Worker:
- Cache First: Сначала ищет необходимые данные в кэше, если кэш отсутствует, то выполняет запрос;
- Network First: Сначала отправляет запрос на сервер, если сервер ничего не вернул, то используется кэш;
- Stale While Revalidate: Отдает ответ на запрос из кэша, пока в фоновом режиме делает запрос на сервер и сохраняет ответ в кэш;
- Network-Only: Всегда использует запрос на сервер, никогда не обращается к кэшу;
- Cache-Only: Всегда обращается к кэшу, никогда не отправляет запрос на сервер;
Если мы используем стратегию Cache First, то она будет выглядеть следующим образом:
const CACHE_VERSION = 'v1';
self.addEventListener('fetch', async (event) => {
const cacheStore = caches.open(CACHE_VERSION);
const cacheResponse = await cacheStore.match(event.request);
if (cacheResponse) {
return cacheResponse;
}
const response = fetch(event.request);
const responseToCache = response.clone();
cacheStore.put(event.request, responseToCache);
return response;
});
Если мы используем стратегию Network First, то она будет выглядеть следующим образом:
self.addEventListener('fetch', async (event) => {
event.respondWith(async () => {
const response = await fetch(event.request);
if (response) {
return response;
}
const cacheStore = caches.open(CACHE_VERSION);
const cachedResponse = await cacheStore.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// У нас нет кэша, то отправляем ошибку с сервера
return response;
});
});
Если мы используем стратегию Stale While Revalidate, то она будет выглядеть следующим образом:
self.addEventListener('fetch', async (event) => {
event.respondWith(async () => {
// Ревалидируем кэш
const response = fetch(event.request).then((res) => {
if (res) {
cacheStore.put(event.request, res.clone());
}
return res;
});
// Пытаемся достать уже существующий кэш
const cacheStore = caches.open(CACHE_VERSION);
const cachedResponse = await cacheStore.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// Дожидаемся ответа сервера (в случае если кэша нет)
const resolvedResponse = await response;
if (resolvedResponse) {
cacheStore.put(event.request, resolvedResponse);
}
return resolvedResponse;
});
});
Для реализации стратегии Network-Only не нужно делать дополнительных действий, мы просто не будем проксировать запросы к серверу, а вот в случае стратегии Cache-Only мы не будем отправлять запросы к серверу, только используем кэш:
self.addEventListener('fetch', async (event) => {
const cacheStore = caches.open(CACHE_VERSION);
const cacheResponse = await cacheStore.match(event.request);
if (cacheResponse) {
return cacheResponse;
}
return null;
});