Supabase广播与状态 #
一、广播消息 #
1.1 什么是Broadcast #
text
Broadcast功能
├── 客户端之间直接通信
├── 低延迟消息传递
├── 无需数据库
├── 适合实时协作
└── 如: 光标位置、打字状态
1.2 发送广播消息 #
typescript
const channel = supabase.channel('room-1')
// 先订阅
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// 发送消息
channel.send({
type: 'broadcast',
event: 'cursor-move',
payload: { x: 100, y: 200 },
})
}
})
1.3 接收广播消息 #
typescript
const channel = supabase
.channel('room-1')
.on('broadcast', { event: 'cursor-move' }, (payload) => {
console.log('Cursor moved:', payload)
})
.subscribe()
1.4 广播聊天示例 #
tsx
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
export function BroadcastChat({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<any[]>([])
const [channel, setChannel] = useState<any>(null)
useEffect(() => {
const ch = supabase
.channel(`chat-${roomId}`)
.on('broadcast', { event: 'message' }, (payload) => {
setMessages((prev) => [...prev, payload.payload])
})
.subscribe()
setChannel(ch)
return () => {
ch.unsubscribe()
}
}, [roomId])
async function sendMessage(content: string) {
const user = (await supabase.auth.getUser()).data.user
channel?.send({
type: 'broadcast',
event: 'message',
payload: {
id: Date.now(),
content,
user_id: user?.id,
user_name: user?.user_metadata?.full_name,
timestamp: new Date().toISOString(),
},
})
}
return (
<div>
<div className="messages">
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.user_name}:</strong> {msg.content}
</div>
))}
</div>
<input
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendMessage(e.currentTarget.value)
e.currentTarget.value = ''
}
}}
/>
</div>
)
}
二、在线状态 #
2.1 什么是Presence #
text
Presence功能
├── 跟踪用户在线状态
├── 自动同步状态
├── 断线自动清理
├── 适合协作应用
└── 如: 在线用户列表、光标位置
2.2 跟踪用户状态 #
typescript
const channel = supabase.channel('room-1')
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Online users:', state)
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
console.log('User joined:', newPresences)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
console.log('User left:', leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
const user = await supabase.auth.getUser()
// 跟踪状态
await channel.track({
user_id: user.data.user?.id,
name: user.data.user?.user_metadata?.full_name,
online_at: new Date().toISOString(),
})
}
})
2.3 在线用户列表示例 #
tsx
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
interface User {
user_id: string
name: string
online_at: string
}
export function OnlineUsers({ roomId }: { roomId: string }) {
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
const channel = supabase.channel(`presence-${roomId}`)
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const usersList: User[] = []
Object.values(state).forEach((presences: any[]) => {
presences.forEach((presence) => {
usersList.push(presence)
})
})
setUsers(usersList)
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
console.log('Users joined:', newPresences)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
console.log('Users left:', leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
const user = (await supabase.auth.getUser()).data.user
await channel.track({
user_id: user?.id,
name: user?.user_metadata?.full_name || 'Anonymous',
online_at: new Date().toISOString(),
})
}
})
return () => {
channel.unsubscribe()
}
}, [roomId])
return (
<div>
<h3>Online Users ({users.length})</h3>
<ul>
{users.map((user) => (
<li key={user.user_id}>
{user.name}
<span className="online-dot" />
</li>
))}
</ul>
</div>
)
}
三、协作光标 #
3.1 光标跟踪示例 #
tsx
import { useEffect, useRef, useState } from 'react'
import { supabase } from '../lib/supabase'
interface Cursor {
user_id: string
name: string
x: number
y: number
color: string
}
export function CollaborativeCanvas({ roomId }: { roomId: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const [cursors, setCursors] = useState<Record<string, Cursor>>({})
const channelRef = useRef<any>(null)
useEffect(() => {
const channel = supabase.channel(`canvas-${roomId}`)
channelRef.current = channel
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const newCursors: Record<string, Cursor> = {}
Object.entries(state).forEach(([key, presences]: [string, any]) => {
const presence = presences[0]
newCursors[key] = presence
})
setCursors(newCursors)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
const user = (await supabase.auth.getUser()).data.user
await channel.track({
user_id: user?.id,
name: user?.user_metadata?.full_name || 'Anonymous',
x: 0,
y: 0,
color: getRandomColor(),
})
}
})
return () => channel.unsubscribe()
}, [roomId])
function handleMouseMove(e: React.MouseEvent) {
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
const x = e.clientX - rect.left
const y = e.clientY - rect.top
channelRef.current?.track({
x,
y,
})
}
return (
<div
ref={containerRef}
onMouseMove={handleMouseMove}
style={{ width: '100%', height: '500px', position: 'relative' }}
>
{Object.values(cursors).map((cursor) => (
<div
key={cursor.user_id}
style={{
position: 'absolute',
left: cursor.x,
top: cursor.y,
pointerEvents: 'none',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill={cursor.color}>
<path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87c.48 0 .72-.58.38-.92L6.35 2.85a.5.5 0 0 0-.85.36Z" />
</svg>
<span style={{ color: cursor.color }}>{cursor.name}</span>
</div>
))}
</div>
)
}
function getRandomColor() {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']
return colors[Math.floor(Math.random() * colors.length)]
}
四、打字状态指示 #
4.1 实现打字状态 #
tsx
import { useState, useEffect, useRef } from 'react'
import { supabase } from '../lib/supabase'
export function TypingIndicator({ roomId }: { roomId: string }) {
const [typingUsers, setTypingUsers] = useState<string[]>([])
const typingTimeout = useRef<NodeJS.Timeout>()
useEffect(() => {
const channel = supabase.channel(`typing-${roomId}`)
channel
.on('broadcast', { event: 'typing-start' }, (payload) => {
setTypingUsers((prev) => {
if (!prev.includes(payload.payload.name)) {
return [...prev, payload.payload.name]
}
return prev
})
})
.on('broadcast', { event: 'typing-stop' }, (payload) => {
setTypingUsers((prev) =>
prev.filter((name) => name !== payload.payload.name)
)
})
.subscribe()
return () => channel.unsubscribe()
}, [roomId])
async function handleTyping() {
const user = (await supabase.auth.getUser()).data.user
const channel = supabase.channel(`typing-${roomId}`)
channel.send({
type: 'broadcast',
event: 'typing-start',
payload: { name: user?.user_metadata?.full_name },
})
clearTimeout(typingTimeout.current)
typingTimeout.current = setTimeout(() => {
channel.send({
type: 'broadcast',
event: 'typing-stop',
payload: { name: user?.user_metadata?.full_name },
})
}, 1000)
}
return (
<div>
<input onChange={handleTyping} />
{typingUsers.length > 0 && (
<p>
{typingUsers.join(', ')}
{typingUsers.length === 1 ? ' is' : ' are'} typing...
</p>
)}
</div>
)
}
五、最佳实践 #
5.1 频道管理 #
typescript
// 使用单例模式管理频道
class ChannelManager {
private channels: Map<string, any> = new Map()
getChannel(name: string) {
if (!this.channels.has(name)) {
this.channels.set(name, supabase.channel(name))
}
return this.channels.get(name)
}
removeChannel(name: string) {
const channel = this.channels.get(name)
if (channel) {
channel.unsubscribe()
this.channels.delete(name)
}
}
}
export const channelManager = new ChannelManager()
5.2 状态同步 #
typescript
// 定期同步状态
useEffect(() => {
const interval = setInterval(() => {
channel.track({
...currentState,
last_sync: new Date().toISOString(),
})
}, 30000) // 每30秒同步
return () => clearInterval(interval)
}, [])
六、总结 #
广播与状态要点:
| 功能 | 方法 |
|---|---|
| 发送广播 | channel.send({ type: ‘broadcast’ }) |
| 接收广播 | channel.on(‘broadcast’, …) |
| 跟踪状态 | channel.track({ … }) |
| 获取状态 | channel.presenceState() |
| 加入事件 | event: ‘join’ |
| 离开事件 | event: ‘leave’ |
下一步,让我们学习Edge Functions!
最后更新:2026-03-28