Возможности Как это работает Тарифы Документация Cookbook Партнёрам Личный кабинет
Фреймворки
💚

Vue.js + Composition API

10 мин чтения Средне

Готовые composables для Vue 3: useChannel, usePresence, useNotifications, useTyping.

Vue 3 Composables TypeScript

📋 Что получится

  • Готовые composables для Vue 3
  • Реактивное подключение к каналам
  • TypeScript поддержка
  • Автоматическая очистка подписок
Vue 3 + Composition API: Современный подход к работе с Pushler. Composables инкапсулируют логику и легко переиспользуются.

⚡ Установка

Bash
# Установка SDK
npm install @pushler/js

🔌 usePushler — базовое подключение

TypeScript
// composables/usePushler.ts
import { ref, onMounted, onUnmounted, readonly } from 'vue';
import PushlerClient from '@pushler/js';

// Singleton клиент
let pushlerInstance: PushlerClient | null = null;

interface UsePushlerOptions {
    appKey: string;
    authEndpoint?: string;
    autoConnect?: boolean;
}

export function usePushler(options: UsePushlerOptions) {
    const isConnected = ref(false);
    const socketId = ref<string | null>(null);
    const error = ref<Error | null>(null);

    // Получаем или создаём singleton
    const getPushler = () => {
        if (!pushlerInstance) {
            pushlerInstance = new PushlerClient({
                appKey: options.appKey,
                authEndpoint: options.authEndpoint || '/api/pushler/auth',
                autoConnect: options.autoConnect ?? true
            });

            pushlerInstance.on('connected', (data) => {
                isConnected.value = true;
                socketId.value = data.socketId;
                error.value = null;
            });

            pushlerInstance.on('disconnected', () => {
                isConnected.value = false;
                socketId.value = null;
            });

            pushlerInstance.on('error', (err) => {
                error.value = err;
            });
        }
        return pushlerInstance;
    };

    const pushler = getPushler();

    const connect = () => pushler.connect();
    const disconnect = () => pushler.disconnect();

    return {
        pushler,
        isConnected: readonly(isConnected),
        socketId: readonly(socketId),
        error: readonly(error),
        connect,
        disconnect
    };
}

📡 useChannel — подписка на канал

TypeScript
// composables/useChannel.ts
import { ref, onMounted, onUnmounted, watch, readonly } from 'vue';
import type { Ref } from 'vue';
import { usePushler } from './usePushler';

interface UseChannelOptions {
    appKey: string;
    authEndpoint?: string;
}

export function useChannel<T = any>(
    channelName: string | Ref<string>, 
    options: UseChannelOptions
) {
    const { pushler, isConnected } = usePushler(options);
    
    const isSubscribed = ref(false);
    const lastMessage = ref<T | null>(null);
    const messages = ref<T[]>([]);
    
    let channel: any = null;
    const eventHandlers = new Map<string, Function[]>();

    const subscribe = () => {
        const name = typeof channelName === 'string' ? channelName : channelName.value;
        if (!name || channel) return;
        
        channel = pushler.subscribe(name);
        
        channel.on('pushler:subscription_succeeded', () => {
            isSubscribed.value = true;
        });
        
        // Перепривязываем обработчики
        eventHandlers.forEach((handlers, event) => {
            handlers.forEach(handler => channel.on(event, handler));
        });
    };

    const unsubscribe = () => {
        if (channel) {
            channel.unsubscribe();
            channel = null;
            isSubscribed.value = false;
        }
    };

    const on = (event: string, callback: (data: T) => void) => {
        if (!eventHandlers.has(event)) {
            eventHandlers.set(event, []);
        }
        eventHandlers.get(event)!.push(callback);
        
        // Если канал уже подписан, добавляем обработчик сразу
        if (channel) {
            channel.on(event, callback);
        }
        
        // Возвращаем функцию отписки
        return () => {
            const handlers = eventHandlers.get(event);
            if (handlers) {
                const idx = handlers.indexOf(callback);
                if (idx > -1) handlers.splice(idx, 1);
            }
            if (channel) {
                channel.off(event, callback);
            }
        };
    };

    // Универсальный слушатель всех событий
    const onMessage = (event: string) => {
        return on(event, (data: T) => {
            lastMessage.value = data;
            messages.value.push(data);
        });
    };

    // Подписываемся при подключении
    onMounted(() => {
        if (isConnected.value) {
            subscribe();
        }
        
        watch(isConnected, (connected) => {
            if (connected) subscribe();
        });
    });

    // Отписываемся при размонтировании
    onUnmounted(() => {
        unsubscribe();
    });

    // Следим за изменением имени канала
    if (typeof channelName !== 'string') {
        watch(channelName, (newName, oldName) => {
            if (newName !== oldName) {
                unsubscribe();
                subscribe();
            }
        });
    }

    return {
        isSubscribed: readonly(isSubscribed),
        lastMessage: readonly(lastMessage),
        messages: readonly(messages),
        on,
        onMessage,
        unsubscribe
    };
}

Использование useChannel

Vue
<template>
  <div>
    <div v-if="!channel.isSubscribed.value">Подключение...</div>
    <div v-else>
      <h3>Последнее сообщение:</h3>
      <pre>{{ channel.lastMessage.value }}</pre>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useChannel } from '@/composables/useChannel';

const channel = useChannel('news', {
    appKey: 'key_ваш_ключ'
});

// Слушаем конкретное событие
channel.on('article:published', (data) => {
    console.log('Новая статья:', data.title);
});
</script>

👥 usePresence — онлайн-пользователи

TypeScript
// composables/usePresence.ts
import { ref, computed, onMounted, onUnmounted, readonly, shallowRef } from 'vue';
import { usePushler } from './usePushler';

interface Member {
    id: string;
    info: Record<string, any>;
}

interface UsePresenceOptions {
    appKey: string;
    authEndpoint?: string;
}

export function usePresence(channelName: string, options: UsePresenceOptions) {
    const { pushler, isConnected } = usePushler(options);
    
    const members = shallowRef<Map<string, Member['info']>>(new Map());
    const isSubscribed = ref(false);
    const myId = ref<string | null>(null);
    
    let channel: any = null;

    // Computed
    const membersList = computed(() => 
        Array.from(members.value.entries()).map(([id, info]) => ({ id, ...info }))
    );
    
    const membersCount = computed(() => members.value.size);
    
    const isMember = (userId: string) => members.value.has(userId);

    const subscribe = () => {
        if (channel) return;
        
        channel = pushler.subscribe(channelName);
        
        channel.on('pushler:subscription_succeeded', (data: any) => {
            isSubscribed.value = true;
            myId.value = data.myId;
            
            const newMembers = new Map<string, Member['info']>();
            if (data.members) {
                Object.entries(data.members).forEach(([id, info]) => {
                    newMembers.set(id, info as Member['info']);
                });
            }
            members.value = newMembers;
        });
        
        channel.on('pushler:member_added', (member: Member) => {
            const newMembers = new Map(members.value);
            newMembers.set(member.id, member.info);
            members.value = newMembers;
        });
        
        channel.on('pushler:member_removed', (member: Member) => {
            const newMembers = new Map(members.value);
            newMembers.delete(member.id);
            members.value = newMembers;
        });
    };

    const unsubscribe = () => {
        if (channel) {
            channel.unsubscribe();
            channel = null;
            isSubscribed.value = false;
            members.value = new Map();
        }
    };

    onMounted(() => {
        if (isConnected.value) subscribe();
        
        pushler.on('connected', subscribe);
    });

    onUnmounted(unsubscribe);

    return {
        members: membersList,
        count: membersCount,
        myId: readonly(myId),
        isSubscribed: readonly(isSubscribed),
        isMember,
        unsubscribe
    };
}

Использование usePresence

Vue
<template>
  <div class="online-users">
    <h3>Онлайн: {{ presence.count.value }}</h3>
    
    <TransitionGroup name="list" tag="ul">
      <li v-for="member in presence.members.value" :key="member.id">
        <img :src="member.avatar" :alt="member.name" />
        <span>{{ member.name }}</span>
        <span v-if="member.id === presence.myId.value">(вы)</span>
      </li>
    </TransitionGroup>
  </div>
</template>

<script setup lang="ts">
import { usePresence } from '@/composables/usePresence';

const props = defineProps<{ chatId: number }>();

const presence = usePresence(`presence-chat-${props.chatId}`, {
    appKey: 'key_ваш_ключ',
    authEndpoint: '/api/pushler/auth'
});
</script>

🔔 useNotifications — уведомления

TypeScript
// composables/useNotifications.ts
import { ref, computed, readonly } from 'vue';
import { useChannel } from './useChannel';

interface Notification {
    id: string;
    title: string;
    message: string;
    type: 'info' | 'success' | 'warning' | 'error';
    timestamp: string;
    read: boolean;
}

interface UseNotificationsOptions {
    appKey: string;
    authEndpoint?: string;
    userId: number | string;
    maxNotifications?: number;
    soundEnabled?: boolean;
}

export function useNotifications(options: UseNotificationsOptions) {
    const notifications = ref<Notification[]>([]);
    const maxNotifications = options.maxNotifications || 50;
    
    const channel = useChannel(`private-user-${options.userId}`, {
        appKey: options.appKey,
        authEndpoint: options.authEndpoint
    });

    // Computed
    const unreadCount = computed(() => 
        notifications.value.filter(n => !n.read).length
    );
    
    const hasUnread = computed(() => unreadCount.value > 0);

    // Слушаем уведомления
    channel.on('notification', (data: Notification) => {
        notifications.value.unshift({ ...data, read: false });
        
        // Ограничиваем количество
        if (notifications.value.length > maxNotifications) {
            notifications.value = notifications.value.slice(0, maxNotifications);
        }
        
        // Звук
        if (options.soundEnabled !== false) {
            playSound();
        }
    });

    const markAsRead = (id: string) => {
        const notification = notifications.value.find(n => n.id === id);
        if (notification) {
            notification.read = true;
        }
    };

    const markAllAsRead = () => {
        notifications.value.forEach(n => n.read = true);
    };

    const remove = (id: string) => {
        const idx = notifications.value.findIndex(n => n.id === id);
        if (idx > -1) {
            notifications.value.splice(idx, 1);
        }
    };

    const clear = () => {
        notifications.value = [];
    };

    const playSound = () => {
        const audio = new Audio('/sounds/notification.mp3');
        audio.volume = 0.3;
        audio.play().catch(() => {});
    };

    return {
        notifications: readonly(notifications),
        unreadCount,
        hasUnread,
        markAsRead,
        markAllAsRead,
        remove,
        clear
    };
}

Использование useNotifications

Vue
<template>
  <div class="notifications">
    <!-- Бейдж -->
    <button @click="showPanel = !showPanel" class="bell-btn">
      🔔
      <span v-if="notifs.hasUnread.value" class="badge">
        {{ notifs.unreadCount.value }}
      </span>
    </button>

    <!-- Панель уведомлений -->
    <Transition name="slide">
      <div v-if="showPanel" class="panel">
        <div class="panel-header">
          <h3>Уведомления</h3>
          <button @click="notifs.markAllAsRead()">Прочитать все</button>
        </div>
        
        <div v-for="n in notifs.notifications.value" :key="n.id" 
             :class="['notification', n.type, { unread: !n.read }]"
             @click="notifs.markAsRead(n.id)">
          <div class="title">{{ n.title }}</div>
          <div class="message">{{ n.message }}</div>
        </div>
        
        <div v-if="notifs.notifications.value.length === 0" class="empty">
          Нет уведомлений
        </div>
      </div>
    </Transition>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useNotifications } from '@/composables/useNotifications';

const showPanel = ref(false);

const notifs = useNotifications({
    appKey: 'key_ваш_ключ',
    authEndpoint: '/api/pushler/auth',
    userId: 123
});
</script>

✍️ useTyping — индикатор печати

TypeScript
// composables/useTyping.ts
import { ref, computed, onUnmounted, readonly } from 'vue';
import { useChannel } from './useChannel';
import { usePushler } from './usePushler';

interface TypingUser {
    id: string;
    name: string;
    timeout: ReturnType<typeof setTimeout>;
}

interface UseTypingOptions {
    appKey: string;
    authEndpoint?: string;
    throttleMs?: number;
    displayMs?: number;
}

export function useTyping(
    channelName: string, 
    currentUserId: string,
    options: UseTypingOptions
) {
    const { pushler } = usePushler(options);
    const channel = useChannel(channelName, options);
    
    const typingUsers = ref<Map<string, TypingUser>>(new Map());
    const throttleMs = options.throttleMs || 2000;
    const displayMs = options.displayMs || 3000;
    
    let lastTypingSent = 0;

    // Computed
    const displayText = computed(() => {
        const names = Array.from(typingUsers.value.values()).map(u => u.name);
        
        if (names.length === 0) return null;
        if (names.length === 1) return `${names[0]} печатает...`;
        if (names.length === 2) return `${names[0]} и ${names[1]} печатают...`;
        return `${names[0]} и ещё ${names.length - 1} печатают...`;
    });
    
    const isTyping = computed(() => typingUsers.value.size > 0);

    // Слушаем события печати
    channel.on('user:typing', (data: { user_id: string; user_name: string }) => {
        if (data.user_id === currentUserId) return;
        
        // Очищаем предыдущий таймаут
        const existing = typingUsers.value.get(data.user_id);
        if (existing?.timeout) clearTimeout(existing.timeout);
        
        // Новый таймаут
        const timeout = setTimeout(() => {
            typingUsers.value.delete(data.user_id);
            typingUsers.value = new Map(typingUsers.value);
        }, displayMs);
        
        typingUsers.value.set(data.user_id, {
            id: data.user_id,
            name: data.user_name,
            timeout
        });
        typingUsers.value = new Map(typingUsers.value);
    });

    // Когда приходит сообщение — убираем индикатор
    channel.on('message:new', (data: { message: { user_id: string } }) => {
        typingUsers.value.delete(data.message.user_id);
        typingUsers.value = new Map(typingUsers.value);
    });

    // Отправка события "печатает"
    const sendTyping = async () => {
        const now = Date.now();
        if (now - lastTypingSent < throttleMs) return;
        
        lastTypingSent = now;
        
        await fetch('/api/typing', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                channel: channelName,
                socket_id: pushler.socketId
            })
        });
    };

    // Очистка
    onUnmounted(() => {
        typingUsers.value.forEach(user => clearTimeout(user.timeout));
    });

    return {
        displayText,
        isTyping,
        sendTyping
    };
}

🎯 Полный пример: Чат

Vue
<!-- ChatRoom.vue -->
<template>
  <div class="chat-room">
    <!-- Онлайн -->
    <aside class="sidebar">
      <h3>Онлайн ({{ presence.count.value }})</h3>
      <ul>
        <li v-for="m in presence.members.value" :key="m.id">
          {{ m.name }}
        </li>
      </ul>
    </aside>

    <!-- Сообщения -->
    <main class="messages">
      <div v-for="msg in messages" :key="msg.id" class="message">
        <strong>{{ msg.user.name }}:</strong> {{ msg.content }}
      </div>
      
      <div v-if="typing.isTyping.value" class="typing">
        {{ typing.displayText.value }}
      </div>
    </main>

    <!-- Ввод -->
    <footer>
      <input v-model="newMessage" @input="typing.sendTyping" 
             @keydown.enter="send" placeholder="Сообщение..." />
    </footer>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useChannel } from '@/composables/useChannel';
import { usePresence } from '@/composables/usePresence';
import { useTyping } from '@/composables/useTyping';

const props = defineProps<{
    chatId: number;
    currentUserId: string;
}>();

const PUSHLER_OPTIONS = {
    appKey: 'key_ваш_ключ',
    authEndpoint: '/api/pushler/auth'
};

const messages = ref([]);
const newMessage = ref('');

// Composables
const channel = useChannel(`private-chat-${props.chatId}`, PUSHLER_OPTIONS);
const presence = usePresence(`presence-chat-${props.chatId}`, PUSHLER_OPTIONS);
const typing = useTyping(`private-chat-${props.chatId}`, props.currentUserId, PUSHLER_OPTIONS);

// Слушаем новые сообщения
channel.on('message:new', (data) => {
    messages.value.push(data.message);
});

// Отправка
async function send() {
    if (!newMessage.value.trim()) return;
    
    await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            chat_id: props.chatId,
            content: newMessage.value
        })
    });
    
    newMessage.value = '';
}
</script>

⚠️ Типичные ошибки

1. Множественные подключения

TypeScript
// ❌ Неправильно — новый клиент при каждом вызове
export function usePushler() {
    const pushler = new PushlerClient({ ... }); // Каждый раз новый!
}

// ✅ Правильно — singleton
let instance: PushlerClient | null = null;

export function usePushler() {
    if (!instance) {
        instance = new PushlerClient({ ... });
    }
    return instance;
}

2. Утечка подписок

TypeScript
// ❌ Неправильно — не отписываемся
onMounted(() => {
    channel.on('message', handler);
});

// ✅ Правильно — очистка в onUnmounted
onMounted(() => {
    channel.on('message', handler);
});

onUnmounted(() => {
    channel.unsubscribe();
});

3. Реактивность Map/Set

TypeScript
// ❌ Неправильно — Vue не отследит изменение
members.value.set(id, data);

// ✅ Правильно — создаём новый Map для триггера реактивности
members.value.set(id, data);
members.value = new Map(members.value);

Готовы попробовать?

Создайте бесплатный аккаунт и начните интеграцию за пару минут