Vue.js

Temps réel avec Socket.io

Implémenter des fonctionnalités en temps réel dans une SPA Vue 3 avec Socket.io client

Dans ce chapitre, on continue de construire notre Show Watchlist en ajoutant des notifications en temps réel : les utilisateurs voient live quand quelqu'un bascule le statut vu d'une série.

HTTP vs WebSocket

Les appels API classiques suivent un modèle requête/réponse : le client demande, le serveur répond, la connexion se ferme. Ce modèle ne convient pas aux fonctionnalités en temps réel (jeu en ligne, chat, notifications...) car le serveur ne peut pas pousser de données sans que le client ne les demande.

WebSocket maintient une connexion bidirectionnelle persistante entre le client et le serveur. Les deux parties peuvent envoyer des messages à tout moment.

Socket.io est une librairie qui simplifie l'utilisation des WebSockets en ajoutant :

  • Reconnexion automatique
  • Gestion des événements nommés
  • Compatibilité navigateurs
  • Authentification via token
HTTP (REST)                    WebSocket / Socket.io
──────────────────             ──────────────────────────────
Client → Serveur (requête)     Client ←→ Serveur (connexion persistante)
Serveur → Client (réponse)     Événements dans les deux sens
Connexion fermée               Connexion maintenue ouverte

Installation

npm install socket.io-client

Connexion au serveur

import { io } from 'socket.io-client'

const socket = io('http://localhost:3001', {
  auth: { token: 'eyJ...' }, // Token JWT pour l'authentification
})

socket.on('connect', () => {
  console.log('Connecté :', socket.id)
})

socket.on('disconnect', () => {
  console.log('Déconnecté')
})

Émettre et écouter des événements

// Émettre un événement vers le serveur
socket.emit('toggleSeen', { movieId: 42, title: 'Inception' })

// Écouter un événement du serveur
socket.on('watchlistUpdated', (data) => {
  console.log(`${data.username} vient de marquer "${data.title}" comme vu`)
})

// Supprimer un listener
socket.off('watchlistUpdated')

Intégration dans un store Pinia

La bonne pratique est d'encapsuler la logique Socket.io dans un store Pinia. Le store gère la connexion, les événements et l'état des notifications. Les composants n'interagissent qu'avec le store.

// src/store/notifications.ts
import { defineStore } from 'pinia'
import { io, type Socket } from 'socket.io-client'
import { computed, ref } from 'vue'

interface WatchlistNotification {
  username: string
  title: string
  movieId: number
  at: string
}

export const useNotificationsStore = defineStore('notifications', () => {
  const socket = ref<Socket | null>(null)
  const socketId = ref<string | null>(null)
  const notifications = ref<WatchlistNotification[]>([])
  const error = ref<string | null>(null)

  const isConnected = computed(() => !!socketId.value)

  // ─── Connexion ───────────────────────────────────────────────

  const connect = (token: string): void => {
    if (socket.value?.connected) return

    const s = io(import.meta.env.VITE_SOCKET_URL, {
      auth: { token },
    })
    socket.value = s

    // Événements système
    s.on('connect', () => {
      socketId.value = s.id ?? null
      error.value = null
    })

    s.on('disconnect', () => {
      socketId.value = null
    })

    s.on('connect_error', (err: Error) => {
      error.value = err.message
    })

    // Événements métier : notification quand un autre utilisateur bascule un film
    s.on('watchlistUpdated', (data: WatchlistNotification) => {
      notifications.value.unshift(data)
    })

    s.on('error', (data: { message: string }) => {
      error.value = data.message
    })
  }

  const disconnect = (): void => {
    socket.value?.disconnect()
    socket.value = null
    socketId.value = null
    error.value = null
  }

  // ─── Actions ─────────────────────────────────────────────────

  const notifyToggleSeen = (movieId: number, title: string): void => {
    socket.value?.emit('toggleSeen', { movieId, title })
  }

  return {
    socketId,
    notifications,
    error,
    isConnected,
    connect,
    disconnect,
    notifyToggleSeen,
  }
})

Utiliser le store dans un composant

On intègre les notifications dans HomePage : à chaque toggleSeen, on émet un événement socket. Les autres utilisateurs connectés reçoivent la notification via watchlistUpdated.

onUnmounted est le symétrique de onMounted — il s'exécute quand le composant est détruit. C'est ici qu'on nettoie la connexion socket pour éviter les fuites mémoire.

<!-- src/pages/HomePage.vue -->
<template>
  <div>
    <p v-if="socketError">Erreur socket : {{ socketError }}</p>

    <!-- Notifications en temps réel -->
    <NCard v-if="notifications.length" title="Activité récente">
      <NSpace vertical>
        <p v-for="(notif, i) in notifications" :key="i">
          <strong>{{ notif.username }}</strong> vient de marquer
          <em>{{ notif.title }}</em> comme vu
        </p>
      </NSpace>
    </NCard>

    <p v-if="isLoading">Chargement...</p>
    <p v-else-if="error">{{ error }}</p>
    <ShowList v-else :shows="shows" @toggle-seen="handleToggleSeen">
      <NSpace justify="space-between" align="center">
        <NInput v-model:value="search" placeholder="Rechercher une série..."/>
        <NTag type="success">{{ seenShows.length }} vu(s) sur {{ shows.length }}</NTag>
      </NSpace>
    </ShowList>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useShows } from '@/composables/useShows'
import { useNotificationsStore } from '@/store/notifications'
import { useAuthStore } from '@/store/auth'
import ShowList from '@/components/ShowList.component.vue'
import type { Show } from '@/types'

const search = ref<string>('')
const { shows, seenShows, isLoading, error, toggleSeen } = useShows()

const notificationsStore = useNotificationsStore()
const authStore = useAuthStore()
const { notifications, error: socketError } = storeToRefs(notificationsStore)

watch(search, (newVal) => {
  console.log('Recherche :', newVal)
})

onMounted(() => {
  if (authStore.token) {
    notificationsStore.connect(authStore.token)
  }
})

onUnmounted(() => {
  notificationsStore.disconnect()
})

const handleToggleSeen = (show: Show) => {
  toggleSeen(show)
  notificationsStore.notifyToggleSeen(show.id, show.name)
}
</script>

Nettoyer les connexions

Toujours se déconnecter dans onUnmounted pour éviter les fuites mémoire et les listeners orphelins :

onUnmounted(() => {
  notificationsStore.disconnect()
})