📋 Что получится
- Готовые 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);
Готовы попробовать?
Создайте бесплатный аккаунт и начните интеграцию за пару минут