Привет, Хабр! Меня зовут Оля, я ведущий программист в отделе разработки интерфейсов ЮMoney. Работаю в команде «Портал» и занимаюсь главной страницей, страницами настроек, онлайн-оплаты и аналитики расходов.

В 2022 году мы, как и многие компании, столкнулись с удалением своих приложений из сторов и искали альтернативные решения. Одним из вариантов было сделать своё прогрессивное веб-приложение (Progressive Web App, PWA).

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

Что такое PWA

Википедия определяет PWA как технологию в веб-разработке, которая визуально и функционально трансформирует сайт в приложение. А так её видит ChatGPT:

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

  • Push API,

  • Web App Manifest,

  • Service Worker,

  • Notifications API,

  • Shape Detection API,

  • JavaScript, CSS, HTML и другие веб-технологии.

Мы обратились к PWA в первую очередь для того, чтобы восстановить пуш-уведомления у наших пользователей на iOS. Поэтому опыт, которым я сегодня поделюсь, будет больше актуален для этой операционной системы. Но технологии, которые рассмотрю, — кросс-браузерные, они работают и на десктопе, и на iOS, и на Android.

Как работают пуш-уведомления в PWA ЮMoney

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

  • Если пользователь даёт разрешение на подписку, мы идём на наш сервер и запрашиваем генерацию специального ключа. Сервер отдаёт нам публичную часть этого ключа.

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

Объект подписки выглядит как JSON-файл, в котором есть два поля: endpoint и keys (набор ключей, которые будут использоваться для дальнейшего шифрования пушей).

У каждого браузера есть служба пуш-сообщений. Например, у браузеров Chrome это Firebase, у Firefox — Mozilla Push Service, а у Apple Safari — Apple Push Notification service (APNs).

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

Технология Push API не нова. В браузерах, подобных Chrome, она появилась ещё в 2015 году, но в Safari — только недавно, в 2022-м, а в Mobile Safari — в 2023-м. В Mobile Safari она работает только в том случае, если пользователь добавил приложение на экран «Домой». Поэтому наша главная задача — подтолкнуть пользователя добавить сайт на экран. В этом помогает всплывающее окно с инструкцией:

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

1.    Подключить манифест.

2.    Зарегистрировать Service Worker.

3.    Запросить разрешение на получение уведомлений.

4.    Получить VAPID-ключ от бэкенда.

5.    Создать подписку.

6. Сохранить её данные на бэкенде.

Рассмотрим технологии, используемые в этих шести шагах

1) WebAppManifest. Это JSON-файл с информацией о нашем приложении. В нём можно настроить различные параметры того, как будет выглядеть веб-приложение: цвет фона (background_color), иконка (icons), название приложения (name), стартовый URL, который будет открываться при запуске. Можно также добавить, например, UTM-метки для статистики.

Так выглядит иконка приложения ЮMoney
Так выглядит иконка приложения ЮMoney

И самое главное — это свойство display, определяющее предпочитаемый браузером вид сайта. Свойство может принимать такие значения, как browser (когда есть элементы управления браузером) и fullscreen (когда элементов управления браузером нет, как и иконок операционной системы). Мы в ЮMoney используем standalone, когда нет элементов управления браузером, но есть иконки операционной системы.

Вот так может выглядеть файл манифеста:

{
    "name": "ЮMoney",
    "short_name": "ЮMoney",
    "display": "standalone",
    "scope": "/",
    "start_url": "/main?utm_source=pwa&utm_medium=direct",
    "theme_color": "#ffffff",
    "background_color": "#ffffff",
    "icons": [
        {
            "src": "https://static.yoomoney.ru/files-front/resources/head/wallet/android-chrome-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "https://static.yoomoney.ru/files-front/resources/head/wallet/android-chrome-512x512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

Подключение манифеста:

<head>
...
<link rel="manifest" href="/site.webmanifest">...
</head>

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

Тут мы столкнулись с проблемой: статические ресурсы, такие как манифест, хранятся у нас на поддомене. Нам пришлось прибегнуть к магии nginx и использовать redirect.

2) Service Worker. Это событийно-управляемый воркер, который представляет собой JavaScript-файл, запускаемый браузером в фоновом режиме. Он обеспечивает кэширование ресурсов для работы в офлайн-режиме, фоновую синхронизацию данных, а также (самое главное для нас) — получение и отправку пуш-уведомлений.

Для работы с Service Worker характерны некоторые особенности:

  • Нет прямого доступа к DOM — взаимодействие с ним происходит через postMessage API.

  • Он запускается в воркер-контексте, в отдельном потоке, не блокируя основной.

  • Он полностью асинхронный — там нельзя использовать синхронный API.

  • Он может перехватывать сетевые запросы, поэтому работает только по HTTPS или локально в целях разработки.

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

Регистрация Service worker:

if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
			return;
		}
try {
// нет необходимости проверять зарегистрирован ли он уже
		const registration = await navigator.serviceWorker.register('/service-worker.js');
    return registration;
} catch (error) {
		return;
}

В этом примере мы подключаем файл, расположенный в корне домена. Он будет распространять своё действие на весь домен.

Здесь мы столкнулись с проблемой, что его нужно хранить на поддомене и нет возможности использовать redirect nginx. Но вместо redirect можно применить rewrite.

В сервис-воркере можно подписаться на множество событий — нас интересуют два: получение пуша и клик на нотификацию.

Пример подписки на события. Так может выглядеть код сервис-воркера, где мы обрабатываем эти два события:

self.addEventListener('push', (event) => {
const notification = event.data.json();

event.waitUntil(
    self.registration.showNotification(
        notification.title,
        {body: notification.body, data: notification.data},
			)
);
});

self.addEventListener('notificationclick', (event) => {
	event.notification.close();

event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
  • При наступлении пуша мы показываем нотификацию с помощью метода showNotification.

  • Во время клика на нотификацию мы открываем URL, который пришёл в данных пуша.

Здесь следует обратить внимание на конструкцию waitUntil. У разработчика мало влияния на то, когда сервис-воркер запускается и останавливается, браузер решает это сам. Этой конструкцией мы говорим, что сервис-воркер занимается важной обработкой и что, пока промис (специальный объект в JavaScript, который используется для написания и обработки асинхронного кода) не завершится, сервис-воркер останавливать не нужно.

3) Notifications API. С помощью этой технологии можно контролировать отображение системных уведомлений, даже если приложение неактивно, пользователь его свернул или переключился на другую вкладку. На этом этапе наша главная задача — инициировать браузером запрос, чтобы пользователь разрешил показывать ему уведомления. Это мы делаем с помощью метода requestPermission.

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

Состояние разрешения может быть в трёх статусах:

  • Default — когда мы ничего не запрашивали и можем запросить, а пользователь разрешит или запретит.

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

  • Granted — когда разрешение на отправку уведомлений получено, мы можем дальше создавать подписку. Для этого нам понадобится VAPID-ключ.

4) VAPID-ключ. VAPID — это добровольная идентификация сервера приложений (Voluntary Application Server Identification). Ключ передаётся службе пуш-сообщений и используется для проверки, что отправляющий пуши сервер — это тот же сервер, на пуши которого подписался пользователь. Бэкенд отдаёт нам публичный ключ, с которым мы создаём подписку на устройстве пользователя.

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

Чтобы получить VAPID-ключ, мы используем fetch API, потому что любой браузер, который поддерживает сервис-воркеры, поддерживает и fetch API.

5) Push API. Даёт возможность получать сообщения с сервера независимо от того, запущено приложение или нет. Среди методов Push API нас интересуют три:

  • Создание подписки. Вызываем метод subscribe у инстанса pushManager регистрации сервис-воркера и передаём туда два параметра. Первый — userVisibleOnly, он должен быть всегда true и говорить о том, что сообщение, которое мы получаем, будет видно пользователю. False сейчас не поддерживает ни один браузер. Второй параметр — applicationServerKey, тот самый публичный ключ, полученный от нашего бэкенда.

const registration = await navigator.serviceWorker.register('/service-worker.js');

const subscription = await registration.pushManager.subscribe({
		userVisibleOnly: true, 
		applicationServerKey: VAPID.publicKey
});
  • Получение подписки. Метод может понадобиться, если нужно проверить, есть ли уже подписка на устройстве пользователя и нужно ли создавать новую. Или если мы хотим получить какие-то данные из подписки, например endpoint или ключи.

  • Отмена подписки. Понадобится, если нужно отписать пользователя — например, когда он разлогинится. У сервис-воркера нет знания о сессиях пользователя, и этим нужно управлять вручную.

6) Сохранение подписки на бэкенд. Финальный шаг из нашего списка. Используем fetch API и передаём на бэкенд данные подписки.

В итоге мы выполнили все шесть шагов:

  • Подключили манифест.

  • Подключили сервис-воркер.

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

  • Запросили и получили у бэкенда VAPID-ключ.

  • Создали подписку.

  • Cохранили её на бэкенде. 

Теперь сервер может отправлять, а устройство — получать пуш-уведомления.

После запуска пуш-сообщений мы проанализировали, как пользователи применяют наш PWA. Выяснилось, что 75% посетителей сайта ЮMoney заходят на него с мобильных устройств и что более 35% пользователей iOS делают это через PWA. Эта цифра постоянно растёт. Команда нашего B2B-направления (ЮKassa) тоже подключила в свой сервис пуш-сообщения. 

В итоге за первые 30 дней после запуска PWA мы отправили пользователям ЮMoney и ЮKassa около миллиона пуш-уведомлений.

Также мы добавили в наше приложение сканирование QR-кодов для быстрой оплаты на кассе. К сожалению, Safari пока не поддерживает Shape Detection API и Barcode Detection API, поэтому мы используем альтернативные библиотеки.

Считаем запуск PWA для iOS удачным и планируем cделать то же самое для Android-устройств.

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

Совместила, так сказать, два своих хобби! =)

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


Если у вас остались вопросы о PWA в ЮMoney (или о моей разработке для любителей настольных игр), с удовольствием отвечу на них в комментариях. ?

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


  1. Negat1v9
    07.02.2025 14:39

    Yoomoney странная платформа, у меня не сложилось поработать с ними. После регистрации как самозанятый, при попытке авторизоваться, все браузеры блокируют сайт, говорят, что «не возможно открыть русском из-за циклических переадресаций»


    1. YooMoneyHelp
      07.02.2025 14:39

      Здравствуйте! Сайт работает штатно. Попробуйте, пожалуйста, очистить сookie и кеш браузера и отключить расширения (VPN, например). Также попробуйте войти с другого браузера (Яндекс, например) и/или подключиться к другому интернет-соединению. Если трудности возникнут снова, напишите нам в лс.


  1. egribanov
    07.02.2025 14:39

    Интересно было бы посмотреть код уведомлений для настолки


  1. slbeat
    07.02.2025 14:39

    Спасибо! Благодаря вашей статье узнал что пуши работают на iOS в PWA. Раньше было стойкое знание что это невозможно.


  1. Also_known_as
    07.02.2025 14:39

    Шёл 25й год , вап девелоперы из яндекса удивлялись pwa и внедряли прорывные технологии. Спасибо тиму коку что спустя 8 лет в сафари появился сервис воркер и пуши, это почти так же продвинуто как внедрение пва в 25ом