Como construir um sistema de notificações in-app sem biblioteca externa

1. Fundamentos do sistema de notificações in-app

Antes de escrever qualquer código, é essencial definir a arquitetura base. Um sistema de notificações in-app pode ser construído com dois modelos principais: event-driven (orientado a eventos) ou polling (verificação periódica). Para um sistema sem dependências externas, o modelo event-driven é mais eficiente, pois reage a mudanças de estado em tempo real sem consumir recursos desnecessários.

A estrutura de dados de cada notificação deve conter:

{
  id: string (UUID ou timestamp único),
  type: string ('info' | 'warning' | 'error' | 'success'),
  title: string,
  message: string,
  timestamp: number (Date.now()),
  priority: number (1-5, onde 5 é mais urgente),
  duration: number (ms para auto-dismiss, 0 = permanente),
  action: { label: string, callback: Function } | null
}

O ciclo de vida de uma notificação segue quatro estágios: criação (evento disparado), exibição (renderização no DOM), interação (clique, fechamento, ação) e descarte (remoção da fila ativa e arquivamento).

2. Implementação do gerenciador central de notificações

O coração do sistema é uma classe singleton NotificationManager que implementa o padrão Observer para reatividade:

class NotificationManager {
  static instance = null;

  constructor() {
    if (NotificationManager.instance) return NotificationManager.instance;
    this.notifications = [];
    this.observers = [];
    this.history = [];
    NotificationManager.instance = this;
  }

  addNotification(notification) {
    const id = 'notif_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
    const newNotif = { ...notification, id, timestamp: Date.now() };
    this.notifications.push(newNotif);
    this._notify();
    if (newNotif.duration > 0) {
      setTimeout(() => this.dismissNotification(id), newNotif.duration);
    }
    return id;
  }

  dismissNotification(id) {
    this.notifications = this.notifications.filter(n => n.id !== id);
    this._notify();
  }

  getActiveNotifications() {
    return [...this.notifications].sort((a, b) => b.priority - a.priority);
  }

  subscribe(fn) {
    this.observers.push(fn);
    return () => { this.observers = this.observers.filter(o => o !== fn); };
  }

  _notify() {
    this.observers.forEach(fn => fn(this.getActiveNotifications()));
  }
}

3. Armazenamento local e persistência

Para manter o histórico de notificações mesmo após recarregar a página, utilizamos localStorage com serialização JSON:

class PersistentNotificationManager extends NotificationManager {
  constructor() {
    super();
    this._loadHistory();
  }

  _saveHistory() {
    const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
    const recentHistory = this.history.filter(n => n.timestamp > thirtyDaysAgo);
    localStorage.setItem('notif_history', JSON.stringify(recentHistory));
  }

  _loadHistory() {
    try {
      const saved = localStorage.getItem('notif_history');
      if (saved) this.history = JSON.parse(saved);
    } catch { this.history = []; }
  }

  dismissNotification(id) {
    const notif = this.notifications.find(n => n.id === id);
    if (notif) {
      this.history.push({ ...notif, dismissedAt: Date.now() });
      this._saveHistory();
    }
    super.dismissNotification(id);
  }
}

A limpeza automática de notificações expiradas deve ser executada na inicialização e a cada 5 minutos:

setInterval(() => {
  const mgr = NotificationManager.instance;
  if (mgr) {
    mgr.notifications = mgr.notifications.filter(n => {
      if (n.duration === 0) return true;
      return (Date.now() - n.timestamp) < n.duration;
    });
    mgr._notify();
  }
}, 300000);

4. Componentização da interface de notificações

Crie um container posicionado no canto superior direito da tela:

function createNotificationContainer(position = 'top-right') {
  const container = document.createElement('div');
  container.id = 'notification-container';
  container.style.cssText = `
    position: fixed;
    z-index: 9999;
    display: flex;
    flex-direction: column;
    gap: 8px;
    max-height: 80vh;
    overflow-y: auto;
    padding: 16px;
  `;

  const positions = {
    'top-right': { top: '0', right: '0' },
    'top-left': { top: '0', left: '0' },
    'bottom-right': { bottom: '0', right: '0' },
    'bottom-left': { bottom: '0', left: '0' }
  };

  Object.assign(container.style, positions[position] || positions['top-right']);
  document.body.appendChild(container);
  return container;
}

Cada notificação é renderizada com template HTML puro e animações CSS:

function renderNotification(notif) {
  const el = document.createElement('div');
  el.className = `notification notification-${notif.type}`;
  el.id = `notif-${notif.id}`;
  el.setAttribute('role', 'alert');
  el.setAttribute('aria-live', 'assertive');

  el.innerHTML = `
    <div class="notif-header">
      <span class="notif-type-icon">${getIconForType(notif.type)}</span>
      <strong class="notif-title">${notif.title}</strong>
      <button class="notif-close" aria-label="Fechar notificação">&times;</button>
    </div>
    <p class="notif-message">${notif.message}</p>
    ${notif.action ? `<button class="notif-action">${notif.action.label}</button>` : ''}
  `;

  el.querySelector('.notif-close').addEventListener('click', () => {
    dismissNotification(notif.id);
  });

  if (notif.action) {
    el.querySelector('.notif-action').addEventListener('click', () => {
      notif.action.callback();
      dismissNotification(notif.id);
    });
  }

  return el;
}

Animações CSS para entrada e saída:

@keyframes slideIn {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

@keyframes fadeOut {
  from { opacity: 1; max-height: 200px; }
  to { opacity: 0; max-height: 0; padding: 0; margin: 0; }
}

.notification {
  animation: slideIn 0.3s ease-out;
  transition: all 0.3s ease;
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  padding: 12px 16px;
  min-width: 300px;
  max-width: 400px;
}

.notification.dismissing {
  animation: fadeOut 0.3s ease-in forwards;
}

5. Lógica de interação e ações

Sistema de fila para evitar sobreposição:

class QueueAwareNotificationManager extends PersistentNotificationManager {
  constructor() {
    super();
    this.queue = [];
    this.maxVisible = 5;
    this.isProcessing = false;
  }

  addNotification(notification) {
    if (this.getActiveNotifications().length >= this.maxVisible) {
      this.queue.push(notification);
      return null;
    }
    return super.addNotification(notification);
  }

  dismissNotification(id) {
    super.dismissNotification(id);
    this._processQueue();
  }

  _processQueue() {
    if (this.queue.length > 0 && this.getActiveNotifications().length < this.maxVisible) {
      const next = this.queue.shift();
      super.addNotification(next);
    }
  }
}

Suporte a diferentes níveis de urgência com cores e ícones:

function getIconForType(type) {
  const icons = {
    info: 'ℹ️',
    warning: '⚠️',
    error: '❌',
    success: '✅'
  };
  return icons[type] || 'ℹ️';
}

function getColorForType(type) {
  const colors = {
    info: '#2196F3',
    warning: '#FF9800',
    error: '#F44336',
    success: '#4CAF50'
  };
  return colors[type] || '#2196F3';
}

6. Otimizações e boas práticas

Debounce para evitar excesso de notificações:

function createDebouncedNotifier(manager, delay = 500) {
  let lastCall = 0;
  return (notification) => {
    const now = Date.now();
    if (now - lastCall < delay) return;
    lastCall = now;
    return manager.addNotification(notification);
  };
}

Controle de duração com timeout automático e acessibilidade:

function addNotificationWithTimeout(manager, notification) {
  const id = manager.addNotification(notification);
  if (notification.duration > 0) {
    setTimeout(() => {
      const el = document.getElementById(`notif-${id}`);
      if (el) {
        el.classList.add('dismissing');
        setTimeout(() => manager.dismissNotification(id), 300);
      }
    }, notification.duration);
  }
  // Foco automático para leitores de tela
  setTimeout(() => {
    const el = document.getElementById(`notif-${id}`);
    if (el) el.focus();
  }, 100);
  return id;
}

7. Integração com o ciclo de vida da aplicação

Notificações condicionais baseadas em estado online/offline:

window.addEventListener('online', () => {
  const mgr = NotificationManager.instance;
  if (mgr) {
    mgr.addNotification({
      type: 'success',
      title: 'Conexão restaurada',
      message: 'Você está online novamente.',
      priority: 3,
      duration: 5000
    });
  }
});

window.addEventListener('offline', () => {
  const mgr = NotificationManager.instance;
  if (mgr) {
    mgr.addNotification({
      type: 'warning',
      title: 'Sem conexão',
      message: 'Algumas funcionalidades podem estar indisponíveis.',
      priority: 4,
      duration: 0
    });
  }
});

Eventos customizados para comunicação entre módulos:

function emitNotificationEvent(eventName, detail) {
  const event = new CustomEvent(eventName, { detail, bubbles: true });
  document.dispatchEvent(event);
}

// Uso em outros módulos
document.addEventListener('notif:action-taken', (e) => {
  console.log('Ação executada:', e.detail);
});

Testes unitários do gerenciador sem dependência de DOM:

// Exemplo de teste (executar em Node.js ou ambiente de teste)
function testNotificationManager() {
  const mgr = new NotificationManager();
  const id = mgr.addNotification({
    type: 'info',
    title: 'Teste',
    message: 'Mensagem de teste',
    priority: 1,
    duration: 0
  });

  console.assert(mgr.getActiveNotifications().length === 1, 'Deveria ter 1 notificação');
  mgr.dismissNotification(id);
  console.assert(mgr.getActiveNotifications().length === 0, 'Deveria ter 0 notificações');
  console.log('Todos os testes passaram!');
}

8. Exemplo prático completo

Abaixo, um sistema funcional mínimo em menos de 200 linhas:

// notification-system.js
class NotificationSystem {
  constructor() {
    this.container = this._createContainer();
    this.manager = new QueueAwareNotificationManager();
    this.manager.subscribe(notifications => this._render(notifications));
  }

  _createContainer() {
    const container = document.createElement('div');
    container.id = 'notif-container';
    container.style.cssText = `
      position: fixed; top: 16px; right: 16px; z-index: 9999;
      display: flex; flex-direction: column; gap: 8px;
      max-width: 400px;
    `;
    document.body.appendChild(container);
    return container;
  }

  _render(notifications) {
    this.container.innerHTML = '';
    notifications.forEach(notif => {
      const el = document.createElement('div');
      el.className = `notif notif-${notif.type}`;
      el.innerHTML = `
        <span>${this._icon(notif.type)}</span>
        <div><strong>${notif.title}</strong><p>${notif.message}</p></div>
        <button onclick="notifSystem.dismiss('${notif.id}')">&times;</button>
      `;
      this.container.appendChild(el);
    });
  }

  _icon(type) {
    return { info: 'ℹ️', warning: '⚠️', error: '❌', success: '✅' }[type] || 'ℹ️';
  }

  notify(type, title, message, duration = 5000) {
    this.manager.addNotification({ type, title, message, priority: 2, duration });
  }

  dismiss(id) {
    this.manager.dismissNotification(id);
  }
}

// Estilos inline (ou adicione via CSS)
const style = document.createElement('style');
style.textContent = `
  .notif { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
    background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    animation: slideIn 0.3s ease-out; }
  .notif-info { border-left: 4px solid #2196F3; }
  .notif-success { border-left: 4px solid #4CAF50; }
  .notif-warning { border-left: 4px solid #FF9800; }
  .notif-error { border-left: 4px solid #F44336; }
  @keyframes slideIn { from { transform: translateX(100%); opacity: 0; }
    to { transform: translateX(0); opacity: 1; } }
`;
document.head.appendChild(style);

// Uso
const notifSystem = new NotificationSystem();
notifSystem.notify('success', 'Operação concluída', 'Arquivo salvo com sucesso.');
notifSystem.notify('error', 'Erro crítico', 'Falha ao conectar ao servidor.', 0);

Para expansão futura, considere integrar com WebSocket para notificações em tempo real ou Service Workers para notificações push. O sistema atual já oferece uma base sólida e extensível.

Referências