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