mcpField
R
real-time-features

real-time-features

github.com/jmsktm开发1066 次安装41 周前更新
代码仓库

关于 real-time-features

real-time-features 是帮助开发者为 Next.js 应用添加实时功能的技能,重点介绍 Supabase Realtime 的订阅、在线状态、广播及交互功能实现方式。

主要功能

  1. 01订阅 Supabase Realtime 表变更
  2. 02按条件筛选事件订阅
  3. 03跟踪用户在线状态
  4. 04通过广播消息支持协作
  5. 05聊天、通知和仪表盘示例
  6. 06连接状态与乐观更新

适用场景

  1. 01构建实时聊天和通知
  2. 02显示用户在线状态
  3. 03添加协作交互功能
  4. 04使用实时数据更新仪表盘

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jmsktm/claude-settings --skill real-time-features

安装后在 Claude Code 中输入/real-time-features确认已加载。

详细介绍

实时功能 Skill

概述

此 Skill 可帮助你在 Next.js 应用中实现实时功能。从实时数据更新到协同编辑,涵盖打造交互式实时体验所需的一切。

Supabase Realtime

设置
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
实时订阅

订阅表变更:

'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function RealtimeMessages() {
  const [messages, setMessages] = useState<Message[]>([])
  const supabase = createClient()

  useEffect(() => {
    // Fetch initial data
    supabase
      .from('messages')
      .select('*')
      .order('created_at', { ascending: true })
      .then(({ data }) => setMessages(data || []))

    // Subscribe to new messages
    const channel = supabase
      .channel('messages')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
        },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message])
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [supabase])

  return (
    <div>
      {messages.map((msg) => (
        <div key={msg.id}>{msg.content}</div>
      ))}
    </div>
  )
}

订阅所有事件:

const channel = supabase
  .channel('messages-all')
  .on(
    'postgres_changes',
    {
      event: '*', // INSERT, UPDATE, DELETE
      schema: 'public',
      table: 'messages',
    },
    (payload) => {
      if (payload.eventType === 'INSERT') {
        setMessages((prev) => [...prev, payload.new as Message])
      }
      if (payload.eventType === 'UPDATE') {
        setMessages((prev) =>
          prev.map((msg) =>
            msg.id === payload.new.id ? (payload.new as Message) : msg
          )
        )
      }
      if (payload.eventType === 'DELETE') {
        setMessages((prev) =>
          prev.filter((msg) => msg.id !== payload.old.id)
        )
      }
    }
  )
  .subscribe()

筛选订阅:

// Only listen to messages in a specific room
const channel = supabase
  .channel(`room:${roomId}`)
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `room_id=eq.${roomId}`,
    },
    (payload) => {
      setMessages((prev) => [...prev, payload.new as Message])
    }
  )
  .subscribe()

自定义 Realtime Hook

// hooks/use-realtime-subscription.ts
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'

type UseRealtimeOptions<T> = {
  table: string
  filter?: string
  event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'
  onInsert?: (record: T) => void
  onUpdate?: (record: T) => void
  onDelete?: (record: T) => void
}

export function useRealtimeSubscription<T>({
  table,
  filter,
  event = '*',
  onInsert,
  onUpdate,
  onDelete,
}: UseRealtimeOptions<T>) {
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase
      .channel(`${table}-changes`)
      .on(
        'postgres_changes',
        {
          event,
          schema: 'public',
          table,
          filter,
        },
        (payload) => {
          if (payload.eventType === 'INSERT' && onInsert) {
            onInsert(payload.new as T)
          }
          if (payload.eventType === 'UPDATE' && onUpdate) {
            onUpdate(payload.new as T)
          }
          if (payload.eventType === 'DELETE' && onDelete) {
            onDelete(payload.old as T)
          }
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [table, filter, event, onInsert, onUpdate, onDelete, supabase])
}

// Usage
function Chat({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([])

  useRealtimeSubscription<Message>({
    table: 'messages',
    filter: `room_id=eq.${roomId}`,
    event: '*',
    onInsert: (msg) => setMessages((prev) => [...prev, msg]),
    onUpdate: (msg) =>
      setMessages((prev) =>
        prev.map((m) => (m.id === msg.id ? msg : m))
      ),
    onDelete: (msg) =>
      setMessages((prev) => prev.filter((m) => m.id !== msg.id)),
  })

  return <div>{/* Render messages */}</div>
}

Presence(谁在线)

跟踪用户在线状态
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

type PresenceState = {
  [key: string]: {
    user_id: string
    username: string
    online_at: string
  }[]
}

export function usePresence(roomId: string) {
  const [onlineUsers, setOnlineUsers] = useState<PresenceState>({})
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`)

    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState<PresenceState>()
        setOnlineUsers(state)
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          // Get current user
          const {
            data: { user },
          } = await supabase.auth.getUser()

          if (user) {
            // Track presence
            await channel.track({
              user_id: user.id,
              username: user.email,
              online_at: new Date().toISOString(),
            })
          }
        }
      })

    return () => {
      channel.untrack()
      supabase.removeChannel(channel)
    }
  }, [roomId, supabase])

  return onlineUsers
}

// Usage
function ChatRoom({ roomId }: { roomId: string }) {
  const onlineUsers = usePresence(roomId)
  const count = Object.keys(onlineUsers).length

  return (
    <div>
      <p>{count} users online</p>
      {Object.values(onlineUsers).map((presences) =>
        presences.map((presence) => (
          <div key={presence.user_id}>{presence.username}</div>
        ))
      )}
    </div>
  )
}

Broadcast(发送自定义消息)

实时协作
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

type CursorPosition = {
  x: number
  y: number
  user_id: string
  username: string
}

export function CollaborativeCanvas({ roomId }: { roomId: string }) {
  const [cursors, setCursors] = useState<CursorPosition[]>([])
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase.channel(`canvas:${roomId}`)

    channel
      .on('broadcast', { event: 'cursor-move' }, ({ payload }) => {
        setCursors((prev) => {
          const filtered = prev.filter(
            (c) => c.user_id !== payload.user_id
          )
          return [...filtered, payload as CursorPosition]
        })
      })
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId, supabase])

  const handleMouseMove = async (e: React.MouseEvent) => {
    const {
      data: { user },
    } = await supabase.auth.getUser()

    if (user) {
      supabase.channel(`canvas:${roomId}`).send({
        type: 'broadcast',
        event: 'cursor-move',
        payload: {
          x: e.clientX,
          y: e.clientY,
          user_id: user.id,
          username: user.email,
        },
      })
    }
  }

  return (
    <div onMouseMove={handleMouseMove}>
      {cursors.map((cursor) => (
        <div
          key={cursor.user_id}
          style={{
            position: 'absolute',
            left: cursor.x,
            top: cursor.y,
            width: 10,
            height: 10,
            borderRadius: '50%',
            backgroundColor: 'blue',
            pointerEvents: 'none',
          }}
        >
          <span>{cursor.username}</span>
        </div>
      ))}
    </div>
  )
}

实时聊天应用

聊天组件
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

type Message = {
  id: string
  content: string
  user_id: string
  username: string
  created_at: string
}

export function LiveChat({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([])
  const [newMessage, setNewMessage] = useState('')
  const [typing, setTyping] = useState<Set<string>>(new Set())
  const supabase = createClient()

  useEffect(() => {
    // Fetch initial messages
    supabase
      .from('messages')
      .select('*')
      .eq('room_id', roomId)
      .order('created_at', { ascending: true })
      .then(({ data }) => setMessages(data || []))

    const channel = supabase.channel(`chat:${roomId}`)

    // Listen for new messages
    channel
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `room_id=eq.${roomId}`,
        },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message])
        }
      )
      // Listen for typing indicators
      .on('broadcast', { event: 'typing' }, ({ payload }) => {
        setTyping((prev) => new Set(prev).add(payload.user_id))
        setTimeout(() => {
          setTyping((prev) => {
            const next = new Set(prev)
            next.delete(payload.user_id)
            return next
          })
        }, 3000)
      })
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId, supabase])

  const sendMessage = async () => {
    if (!newMessage.trim()) return

    const {
      data: { user },
    } = await supabase.auth.getUser()

    if (!user) return

    await supabase.from('messages').insert({
      content: newMessage,
      room_id: roomId,
      user_id: user.id,
      username: user.email,
    })

    setNewMessage('')
  }

  const handleTyping = async () => {
    const {
      data: { user },
    } = await supabase.auth.getUser()

    if (user) {
      supabase.channel(`chat:${roomId}`).send({
        type: 'broadcast',
        event: 'typing',
        payload: { user_id: user.id },
      })
    }
  }

  return (
    <div>
      <div className="h-96 overflow-y-auto">
        {messages.map((msg) => (
          <div key={msg.id}>
            <strong>{msg.username}:</strong> {msg.content}
          </div>
        ))}
      </div>

      {typing.size > 0 && (
        <p className="text-sm text-gray-500">
          {typing.size} {typing.size === 1 ? 'person is' : 'people are'} typing...
        </p>
      )}

      <div className="flex gap-2">
        <input
          value={newMessage}
          onChange={(e) => {
            setNewMessage(e.target.value)
            handleTyping()
          }}
          onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="Type a message..."
        />
        <button onClick={sendMessage}>Send</button>
      </div>
    </div>
  )
}

实时通知

通知系统
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

type Notification = {
  id: string
  title: string
  message: string
  read: boolean
  created_at: string
}

export function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([])
  const [unreadCount, setUnreadCount] = useState(0)
  const supabase = createClient()

  useEffect(() => {
    const fetchNotifications = async () => {
      const {
        data: { user },
      } = await supabase.auth.getUser()

      if (!user) return

      const { data } = await supabase
        .from('notifications')
        .select('*')
        .eq('user_id', user.id)
        .order('created_at', { ascending: false })

      setNotifications(data || [])
      setUnreadCount(data?.filter((n) => !n.read).length || 0)
    }

    fetchNotifications()

    const channel = supabase
      .channel('notifications')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'notifications',
        },
        (payload) => {
          setNotifications((prev) => [payload.new as Notification, ...prev])
          setUnreadCount((prev) => prev + 1)

          // Show browser notification
          if ('Notification' in window && Notification.permission === 'granted') {
            new Notification(payload.new.title, {
              body: payload.new.message,
            })
          }
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [supabase])

  const markAsRead = async (id: string) => {
    await supabase
      .from('notifications')
      .update({ read: true })
      .eq('id', id)

    setNotifications((prev) =>
      prev.map((n) => (n.id === id ? { ...n, read: true } : n))
    )
    setUnreadCount((prev) => prev - 1)
  }

  return (
    <div>
      <button className="relative">
        🔔
        {unreadCount > 0 && (
          <span className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 text-xs">
            {unreadCount}
          </span>
        )}
      </button>

      <div>
        {notifications.map((notification) => (
          <div
            key={notification.id}
            className={notification.read ? 'opacity-50' : ''}
            onClick={() => markAsRead(notification.id)}
          >
            <h4>{notification.title}</h4>
            <p>{notification.message}</p>
          </div>
        ))}
      </div>
    </div>
  )
}

实时仪表盘更新

实时分析
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

type Stats = {
  total_users: number
  active_sessions: number
  revenue_today: number
}

export function LiveDashboard() {
  const [stats, setStats] = useState<Stats>({
    total_users: 0,
    active_sessions: 0,
    revenue_today: 0,
  })
  const supabase = createClient()

  useEffect(() => {
    // Fetch initial stats
    const fetchStats = async () => {
      const { data } = await supabase.rpc('get_dashboard_stats')
      setStats(data)
    }

    fetchStats()

    // Listen for relevant table changes
    const channel = supabase
      .channel('dashboard-updates')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'users',
        },
        () => {
          setStats((prev) => ({
            ...prev,
            total_users: prev.total_users + 1,
          }))
        }
      )
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'orders',
        },
        (payload) => {
          setStats((prev) => ({
            ...prev,
            revenue_today: prev.revenue_today + payload.new.amount,
          }))
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [supabase])

  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="p-4 bg-white rounded shadow">
        <h3>Total Users</h3>
        <p className="text-3xl font-bold">{stats.total_users}</p>
      </div>
      <div className="p-4 bg-white rounded shadow">
        <h3>Active Sessions</h3>
        <p className="text-3xl font-bold">{stats.active_sessions}</p>
      </div>
      <div className="p-4 bg-white rounded shadow">
        <h3>Revenue Today</h3>
        <p className="text-3xl font-bold">${stats.revenue_today}</p>
      </div>
    </div>
  )
}

乐观更新

即时 UI 更新
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([])
  const supabase = createClient()

  const addTodo = async (text: string) => {
    // Optimistic update - update UI immediately
    const tempId = crypto.randomUUID()
    const optimisticTodo = {
      id: tempId,
      text,
      done: false,
      created_at: new Date().toISOString(),
    }

    setTodos((prev) => [...prev, optimisticTodo])

    try {
      // Insert to database
      const { data, error } = await supabase
        .from('todos')
        .insert({ text, done: false })
        .select()
        .single()

      if (error) throw error

      // Replace temp todo with real one
      setTodos((prev) =>
        prev.map((todo) => (todo.id === tempId ? data : todo))
      )
    } catch (error) {
      // Rollback on error
      setTodos((prev) => prev.filter((todo) => todo.id !== tempId))
      console.error('Failed to add todo:', error)
    }
  }

  return <div>{/* Render todos */}</div>
}

连接状态

跟踪连接状态
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function ConnectionStatus() {
  const [isConnected, setIsConnected] = useState(true)
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase.channel('connection-test')

    channel
      .on('system', { event: 'online' }, () => setIsConnected(true))
      .on('system', { event: 'offline' }, () => setIsConnected(false))
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [supabase])

  if (!isConnected) {
    return (
      <div className="bg-red-500 text-white p-2 text-center">
        ⚠️ Connection lost. Reconnecting...
      </div>
    )
  }

  return null
}

最佳实践清单

  • 组件卸载时清理订阅
  • 妥善处理连接错误
  • 实施乐观更新以改善用户体验
  • 使用 presence 显示在线状态
  • 在聊天中实现正在输入指示器
  • 向用户显示连接状态
  • 对广播消息进行速率限制
  • 使用筛选条件减少不必要的更新
  • 实现重新连接逻辑
  • 处理重复消息
  • 高效使用频道
  • 在慢速或离线连接下进行测试
  • 为离线消息实现队列机制
  • 监控实时功能的使用情况和成本

何时使用此 Skill

在以下情况调用此 Skill:

  • 构建聊天应用
  • 实现实时通知
  • 创建协作功能
  • 添加在线状态
  • 构建实时仪表盘
  • 实现实时更新
  • 创建多人游戏功能
  • 调试实时连接
  • 优化实时性能
  • 实现正在输入指示器

评论

支持 Markdown