跳至主要內容

Supabase Realtime 即時訂閱:打造即時協作應用

Supabase Realtime 即時訂閱:打造即時協作應用

即時功能(Real-time)是現代應用的重要特性。聊天室、協作文件、即時通知、線上遊戲……這些場景都需要伺服器能主動推送資料到客戶端。

過去實作這類功能需要自己架設 WebSocket 伺服器、管理連線狀態,相當繁瑣。Supabase Realtime 讓這件事變得非常簡單——它建立在 PostgreSQL 的邏輯複製機制(Logical Replication)之上,讓你可以訂閱資料庫的變更,當有資料新增、修改或刪除時,立刻收到通知。

Supabase Realtime 的三種機制

1. Postgres Changes

監聽資料庫表的 INSERT、UPDATE、DELETE 事件:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
)

// 訂閱 messages 表的所有變更
const channel = supabase
  .channel('messages-changes')
  .on(
    'postgres_changes',
    {
      event: '*',  // INSERT | UPDATE | DELETE | *
      schema: 'public',
      table: 'messages'
    },
    (payload) => {
      console.log('資料變更:', payload)

      if (payload.eventType === 'INSERT') {
        addMessageToUI(payload.new)
      } else if (payload.eventType === 'UPDATE') {
        updateMessageInUI(payload.new)
      } else if (payload.eventType === 'DELETE') {
        removeMessageFromUI(payload.old)
      }
    }
  )
  .subscribe()

// 取消訂閱
supabase.removeChannel(channel)

2. Broadcast

讓客戶端之間互相傳送訊息,不經過資料庫:

// 建立頻道
const channel = supabase.channel('room:game-lobby')

// 訂閱廣播
channel.on(
  'broadcast',
  { event: 'cursor_move' },
  (payload) => {
    updateCursorPosition(payload.payload.userId, payload.payload.x, payload.payload.y)
  }
)

channel.subscribe()

// 發送廣播(其他訂閱者會收到,但發送者自己不收)
await channel.send({
  type: 'broadcast',
  event: 'cursor_move',
  payload: {
    userId: currentUserId,
    x: mouseX,
    y: mouseY
  }
})

3. Presence

追蹤目前線上的使用者:

const channel = supabase.channel('room:document-123')

// 監聽 Presence 事件
channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    console.log('目前線上使用者:', state)
    updateOnlineUsers(state)
  })
  .on('presence', { event: 'join' }, ({ key, newPresences }) => {
    console.log('使用者加入:', newPresences)
  })
  .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
    console.log('使用者離開:', leftPresences)
  })

await channel.subscribe(async (status) => {
  if (status === 'SUBSCRIBED') {
    // 追蹤自己的狀態
    await channel.track({
      userId: currentUser.id,
      username: currentUser.name,
      avatar: currentUser.avatarUrl,
      cursor: null
    })
  }
})

實戰:打造即時聊天室

讓我們用 Vue 3 + Supabase Realtime 打造一個完整的聊天室:

<!-- ChatRoom.vue -->
<template>
  <div class="chat-room">
    <!-- 線上使用者 -->
    <div class="online-users">
      <span class="online-count">{{ onlineUsers.length }} 人在線</span>
      <div class="user-avatars">
        <img
          v-for="user in onlineUsers"
          :key="user.userId"
          :src="user.avatar"
          :title="user.username"
          class="avatar"
        />
      </div>
    </div>

    <!-- 訊息列表 -->
    <div class="messages" ref="messagesEl">
      <div
        v-for="msg in messages"
        :key="msg.id"
        :class="['message', { 'own': msg.user_id === currentUser.id }]"
      >
        <img :src="msg.users.avatar_url" class="avatar" />
        <div class="bubble">
          <span class="username">{{ msg.users.username }}</span>
          <p>{{ msg.content }}</p>
          <time>{{ formatTime(msg.created_at) }}</time>
        </div>
      </div>
    </div>

    <!-- 輸入框 -->
    <form @submit.prevent="sendMessage" class="message-form">
      <input
        v-model="newMessage"
        placeholder="輸入訊息..."
        :disabled="!isConnected"
      />
      <button type="submit" :disabled="!newMessage.trim()">送出</button>
    </form>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { supabase } from '@/lib/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'

interface Message {
  id: string
  content: string
  user_id: string
  created_at: string
  users: {
    username: string
    avatar_url: string
  }
}

interface OnlineUser {
  userId: string
  username: string
  avatar: string
}

const props = defineProps<{ roomId: string; currentUser: any }>()

const messages = ref<Message[]>([])
const onlineUsers = ref<OnlineUser[]>([])
const newMessage = ref('')
const isConnected = ref(false)
const messagesEl = ref<HTMLElement>()

let channel: RealtimeChannel

onMounted(async () => {
  // 載入歷史訊息
  await loadMessages()

  // 建立 Realtime 頻道
  channel = supabase.channel(`room:${props.roomId}`)

  // 監聽新訊息
  channel.on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `room_id=eq.${props.roomId}`
    },
    async (payload) => {
      // 取得完整訊息(含使用者資訊)
      const { data } = await supabase
        .from('messages')
        .select('*, users(username, avatar_url)')
        .eq('id', payload.new.id)
        .single()

      if (data) {
        messages.value.push(data)
        await nextTick()
        scrollToBottom()
      }
    }
  )

  // Presence:追蹤線上使用者
  channel
    .on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState<OnlineUser>()
      onlineUsers.value = Object.values(state).flat()
    })
    .subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        isConnected.value = true
        await channel.track({
          userId: props.currentUser.id,
          username: props.currentUser.username,
          avatar: props.currentUser.avatar_url
        })
      }
    })
})

onUnmounted(() => {
  supabase.removeChannel(channel)
})

async function loadMessages() {
  const { data } = await supabase
    .from('messages')
    .select('*, users(username, avatar_url)')
    .eq('room_id', props.roomId)
    .order('created_at', { ascending: true })
    .limit(50)

  if (data) {
    messages.value = data
    await nextTick()
    scrollToBottom()
  }
}

async function sendMessage() {
  if (!newMessage.value.trim()) return

  await supabase.from('messages').insert({
    room_id: props.roomId,
    user_id: props.currentUser.id,
    content: newMessage.value.trim()
  })

  newMessage.value = ''
}

function scrollToBottom() {
  if (messagesEl.value) {
    messagesEl.value.scrollTop = messagesEl.value.scrollHeight
  }
}

function formatTime(dateStr: string) {
  return new Date(dateStr).toLocaleTimeString('zh-TW', {
    hour: '2-digit',
    minute: '2-digit'
  })
}
</script>

設定 Row Level Security

在使用 Realtime 時,Row Level Security(RLS)仍然有效。確保你的資料表設定了正確的政策:

-- 啟用 RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- 已登入的使用者可以讀取訊息
CREATE POLICY "已登入使用者可讀取訊息"
ON messages FOR SELECT
TO authenticated
USING (true);

-- 使用者只能插入自己的訊息
CREATE POLICY "使用者只能新增自己的訊息"
ON messages FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);

效能考量

  1. 使用 filter 縮小範圍:訂閱時盡量加上 filter,避免接收不相關的變更
  2. 適當取消訂閱:元件卸載時務必 removeChannel,避免記憶體洩漏
  3. Broadcast 優先於 Postgres Changes:對於不需要持久化的資料(如游標位置),使用 Broadcast 效能更好
  4. 批次更新 UI:接收到大量即時更新時,考慮使用 debounce 批次更新

總結

Supabase Realtime 讓即時功能的實作門檻大幅降低。透過 Postgres Changes、Broadcast 和 Presence 三種機制,你可以覆蓋大多數即時應用的需求。結合 RLS 保障資料安全,加上 Vue 3 的響應式系統,就能快速打造出功能完整的協作應用。

分享這篇文章