关于 real-time-features
real-time-features 是帮助开发者为 Next.js 应用添加实时功能的技能,重点介绍 Supabase Realtime 的订阅、在线状态、广播及交互功能实现方式。
主要功能
- 01订阅 Supabase Realtime 表变更
- 02按条件筛选事件订阅
- 03跟踪用户在线状态
- 04通过广播消息支持协作
- 05聊天、通知和仪表盘示例
- 06连接状态与乐观更新
适用场景
- 01构建实时聊天和通知
- 02显示用户在线状态
- 03添加协作交互功能
- 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