8. Push-уведомления
Уведомления в реальном времени — ключевая фича любого современного приложения. Разберём несколько подходов.
Варианты реализации
Заголовок раздела «Варианты реализации»1. In-App уведомления (Socket.io/SSE) → Пользователь онлайн на сайте → Моментально через WebSocket
2. Web Push Notifications (Push API + Service Worker) → Пользователь оффлайн или на другой вкладке → Нативные уведомления ОС/браузера
3. Email/SMS уведомления → Для важных событий, когда пользователь давно оффлайн1. In-App уведомления через Socket.io
Заголовок раздела «1. In-App уведомления через Socket.io»Простейший вариант — отправить событие через WebSocket:
const userSockets = new Map(); // userId → Set<socketId>
io.on('connection', (socket) => { const userId = socket.data.userId;
// Регистрируем сокет пользователя if (!userSockets.has(userId)) { userSockets.set(userId, new Set()); } userSockets.get(userId).add(socket.id);
socket.on('disconnect', () => { userSockets.get(userId)?.delete(socket.id); if (userSockets.get(userId)?.size === 0) { userSockets.delete(userId); } });});
// Отправить уведомление пользователюasync function notifyUser(userId, notification) { const socketIds = userSockets.get(userId);
// Сохранить в БД (для пропущенных уведомлений) const saved = await db.notification.create({ data: { userId, type: notification.type, title: notification.title, body: notification.body, data: JSON.stringify(notification.data || {}), read: false, }, });
if (socketIds?.size > 0) { // Пользователь онлайн — отправить в реальном времени socketIds.forEach((socketId) => { io.to(socketId).emit('notification', { id: saved.id, ...notification, timestamp: saved.createdAt, }); }); } // Если оффлайн — уведомление сохранено в БД для получения при входе}
// При подключении — отправить непрочитанныеio.on('connection', async (socket) => { const { userId } = socket.data;
const unread = await db.notification.findMany({ where: { userId, read: false }, orderBy: { createdAt: 'desc' }, take: 50, });
socket.emit('unread-notifications', unread);});
// API для отметки прочитаннымapp.patch('/api/notifications/:id/read', async (req, res) => { const { id } = req.params; const userId = req.user.id;
await db.notification.update({ where: { id, userId }, data: { read: true }, });
// Синхронизировать статус по другим вкладкам userSockets.get(userId)?.forEach((socketId) => { io.to(socketId).emit('notification-read', { id }); });
res.json({ success: true });});2. Web Push Notifications
Заголовок раздела «2. Web Push Notifications»Уведомления даже когда браузер закрыт:
Установка
Заголовок раздела «Установка»npm install web-pushnpx web-push generate-vapid-keysconst webpush = require('web-push');
// VAPID ключи (сгенерировать один раз, хранить в .env)webpush.setVapidDetails( process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY);
// Сохранить подписку пользователяapp.post('/api/push/subscribe', express.json(), async (req, res) => { const subscription = req.body; const userId = req.user.id;
await db.pushSubscription.upsert({ where: { userId }, create: { userId, subscription: JSON.stringify(subscription) }, update: { subscription: JSON.stringify(subscription) }, });
res.json({ success: true });});
// Отправить push-уведомлениеasync function sendPushNotification(userId, notification) { const subs = await db.pushSubscription.findMany({ where: { userId }, });
const payload = JSON.stringify({ title: notification.title, body: notification.body, icon: '/icon-192x192.png', badge: '/badge-72x72.png', data: { url: notification.url }, });
const results = await Promise.allSettled( subs.map(({ subscription }) => webpush.sendNotification(JSON.parse(subscription), payload) ) );
// Удалить невалидные подписки results.forEach((result, i) => { if (result.status === 'rejected' && result.reason.statusCode === 410) { db.pushSubscription.delete({ where: { id: subs[i].id } }); } });}Клиент — Service Worker
Заголовок раздела «Клиент — Service Worker»self.addEventListener('push', (event) => { const data = event.data.json();
const options = { body: data.body, icon: data.icon, badge: data.badge, data: data.data, actions: [ { action: 'open', title: 'Открыть' }, { action: 'dismiss', title: 'Закрыть' }, ], };
event.waitUntil( self.registration.showNotification(data.title, options) );});
self.addEventListener('notificationclick', (event) => { event.notification.close();
if (event.action === 'dismiss') return;
const url = event.notification.data?.url || '/';
event.waitUntil( clients.matchAll({ type: 'window' }).then((clientList) => { // Найти открытую вкладку const existingWindow = clientList.find((client) => client.url === url && 'focus' in client );
if (existingWindow) { return existingWindow.focus(); }
return clients.openWindow(url); }) );});Клиент — регистрация
Заголовок раздела «Клиент — регистрация»// Регистрация Service Worker и подписка на pushasync function subscribeToPush() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { console.warn('Push уведомления не поддерживаются'); return; }
// Запросить разрешение const permission = await Notification.requestPermission(); if (permission !== 'granted') { console.warn('Пользователь отклонил уведомления'); return; }
// Зарегистрировать Service Worker const registration = await navigator.serviceWorker.register('/sw.js');
// Подписаться на push const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), });
// Отправить подписку на сервер await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription), });}
function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));}Полная система уведомлений
Заголовок раздела «Полная система уведомлений»class NotificationService { constructor(io) { this.io = io; this.userSockets = new Map(); }
// Зарегистрировать сокет пользователя register(userId, socketId) { if (!this.userSockets.has(userId)) { this.userSockets.set(userId, new Set()); } this.userSockets.get(userId).add(socketId); }
// Отписать сокет unregister(userId, socketId) { this.userSockets.get(userId)?.delete(socketId); }
// Отправить уведомление async send(userId, { type, title, body, data = {}, url = '/' }) { // Сохранить в БД const notification = await db.notification.create({ data: { userId, type, title, body, data: JSON.stringify(data), read: false }, });
const isOnline = (this.userSockets.get(userId)?.size || 0) > 0;
if (isOnline) { // In-app уведомление this.userSockets.get(userId).forEach((socketId) => { this.io.to(socketId).emit('notification', notification); }); } else { // Web Push (если подписан) await sendPushNotification(userId, { title, body, url }); }
return notification; }
// Отправить всем async broadcast({ type, title, body }) { const notification = { type, title, body, timestamp: new Date() }; this.io.emit('global-notification', notification);
// Пользователям оффлайн — push const allUsers = await db.user.findMany({ select: { id: true } }); await Promise.all( allUsers.map(({ id }) => !this.userSockets.has(id) ? sendPushNotification(id, { title, body }) : null ) ); }}Задания
Заголовок раздела «Задания»- Реализуй систему с двумя каналами: Socket.io (если онлайн) → Web Push (если оффлайн)
- Добавь категории уведомлений и настройки (какие хочу, какие нет)
- Сделай колокольчик с числом непрочитанных уведомлений в React
- Реализуй мягкий сброс “всё прочитано”
In-app уведомления через Socket.io — просто и быстро. Web Push — для оффлайн-пользователей. Комбинируй оба подхода для полного покрытия. В следующем уроке — сравнение всех технологий real-time.