Supabase Realtime Presence 線上狀態:誰在線上與 typing | Supabase 完整教學
上一篇的 Broadcast 幫我們把「稍縱即逝的高頻互動」做到毫秒級即時。這一篇要解決協作場景裡另一個經典需求——「現在有哪些人在線上?」 Presence(線上狀態) 是 Supabase Realtime 的第三種能力:每個客戶端用
channel.track(state)登記自己的狀態,Supabase 底層用 CRDT 在分散式環境自動同步這份共享名單,你只要監聽sync/join/leave事件、用presenceState()讀取,就能做出線上頭像、參與者列表與 typing 打字指示。你也會學到用config: { presence: { key } }讓同一人的多分頁多裝置去重,以及人數上限與track頻率的效能注意事項。
前言
一句話定義本篇主題:Presence 是 Supabase Realtime 提供的「共享線上狀態」機制——每個客戶端呼叫 channel.track(state) 把自己的狀態(線上、頭像、正在做什麼)登記到 channel,Supabase 用 CRDT 在所有訂閱者之間自動同步,任何人隨時可用 channel.presenceState() 讀到「現在誰在線上、各自狀態如何」。
先接住前兩篇的脈絡。Postgres Changes(第 027 篇)管「資料有沒有寫進資料庫」,Broadcast(上一篇)管「當下的高頻訊息傳遞」。可是「誰在線上」這件事,兩者都不好做:
- 用 Postgres Changes?你得建一張
online_users表,上線INSERT、下線DELETE。但問題來了——使用者直接關掉分頁、網路斷線、瀏覽器當掉,根本不會觸發你的下線邏輯,結果表裡塞滿「幽靈上線」的殭屍紀錄,越積越髒。 - 用 Broadcast?你可以廣播「我上線了」,但 Broadcast 不持久化、沒有狀態——晚加入的人收不到之前的上線廣播,你還得自己手動維護一份名單、自己處理去重與清理,等於重造輪子。
Presence 就是為這個需求量身打造的。 它天生「有狀態」:track() 上線、untrack()(或斷線)自動下線,且斷線會自動清理——使用者關掉分頁、網路斷了,Supabase 偵測到 WebSocket 斷開就自動把他從名單移除,你完全不用管殭屍紀錄。底層用 CRDT(Conflict-free Replicated Data Type,無衝突複製資料型別)確保即使在全球分散式叢集下,這份名單也能最終一致。
用一個生活類比幫你抓住三者的差別:
- Postgres Changes 像簽到簿:白紙黑字留底,但要靠人自己簽退,忘了簽退就一直掛在上面。
- Broadcast 像喊一聲「我來了」:當下在場的人聽得到,晚到的人錯過就錯過,沒人幫你記名單。
- Presence 像會議室的自動感應燈與人數看板:人進來燈亮、人一走(哪怕是偷偷溜掉)系統自動偵測並更新人數,永遠反映「現在實際在場的人」。
本篇你會學到:
- Presence 的生命週期:
track/untrack與sync/join/leave三個事件如何協作 - 讀取線上名單:
presenceState()的資料結構,以及怎麼渲染成頭像列與參與者列表 - typing 打字指示:用 Presence(或搭配 Broadcast)做「某某正在輸入…」
- 多分頁/多裝置去重:用
config: { presence: { key } }把同一人的多個連線歸併為一 - 效能與上限:
track頻率限制、人數規模注意事項、背景分頁節流的處理
核心概念
要用好 Presence,先建立三張心智圖:一張是「Presence 在三大功能中的定位」,一張是「一次上線到下線的完整生命週期」,一張是「presenceState() 的資料結構」。
一、Presence 在三大 Realtime 功能中的定位
| 維度 | Presence(本篇) | Broadcast(上一篇) | Postgres Changes(第 027 篇) |
|---|---|---|---|
| 本質 | 共享狀態(誰在線上) | 稍縱即逝的訊息 | 資料庫真實變更 |
| 有無狀態 | 有(自動同步的名單) | 無 | 有(資料存在 DB) |
| 斷線處理 | 自動清理(幽靈自動移除) | 不適用 | 需自己 DELETE,易殘留 |
| 典型場景 | 線上頭像、參與者列表、typing 狀態 | 游標、打字事件、遊戲操作 | 訂單狀態、聊天訊息、通知 |
| 更新頻率 | 中低頻(狀態變化才更新) | 高頻 | 低至中頻 |
| 底層機制 | CRDT(Phoenix Tracker) | 伺服器端純路由 | WAL 邏輯複製 + RLS |
一句話抓住定位:要顯示「一份會自動同步、斷線自動清理的線上名單」,就用 Presence。 這正是上一篇結尾埋的伏筆——「誰在線上」這件事,既不是資料庫變更、也不該用一堆 Broadcast 手動維護,交給 Presence 最省心。
二、一次上線到下線的完整生命週期
Presence 的三個事件 sync / join / leave 常讓初學者搞混。用一條時間線串起來就清楚了:
[客戶端 A 訂閱並 track 自己]
│
▼
1. A 呼叫 channel.subscribe(),狀態變為 SUBSCRIBED
│
▼
2. A 在 SUBSCRIBED 後呼叫 channel.track({ userId, ... })
→ 觸發【sync】:A 收到「當前完整名單」(此刻可能只有 A 自己)
│
▼
[客戶端 B 稍後加入同一 channel 並 track]
│
▼
3. B track 之後:
→ A 收到【join】事件(newPresences 裡是 B 的資料)
→ B 收到【sync】:B 拿到當前完整名單(含 A 和 B)
│
▼
[客戶端 B 離開(呼叫 untrack、關分頁、或斷線)]
│
▼
4. → A 收到【leave】事件(leftPresences 裡是 B 的資料)
(若 B 是直接斷線,Supabase 偵測 WebSocket 斷開後自動觸發)
三個事件的分工可以這樣記:
| 事件 | 觸發時機 | 你該做什麼 |
|---|---|---|
sync | 狀態重新同步時(初次訂閱、或狀態對賬) | 呼叫 presenceState() 重讀全量名單,重繪整份 UI |
join | 有新客戶端開始 track() | 拿 newPresences 做增量更新(如彈出「某某加入了」) |
leave | 有客戶端 untrack() 或斷線 | 拿 leftPresences 做增量更新(如移除該人的頭像) |
最重要的實務觀念:顯示名單的主邏輯應該掛在 sync(因為它給你「完整真相」),join / leave 只是拿來做「加入/離開」的即時提示或動畫。很多人只監聽 join 卻忘了 sync,結果初次載入時名單是空的——因為初始的全量狀態是透過 sync 送來的。另外要注意:sync 期間可能夾帶 join / leave,那是 CRDT 的狀態對賬,不一定代表真的有人進出。
三、presenceState() 的資料結構
channel.presenceState() 回傳「當前所有線上狀態」,結構是一個物件:key 是每個客戶端的 presence key,value 是該 key 底下的 presence 陣列。
// presenceState() 回傳的結構大致長這樣:
{
"user-abc": [
{ presence_ref: "自動生成的唯一碼", userId: "user-abc", username: "Alice", online_at: "..." }
],
"user-def": [
{ presence_ref: "...", userId: "user-def", username: "Bob", online_at: "..." }
]
}
三個關鍵術語:
| 概念 | 說明 |
|---|---|
| presence key | 每個客戶端的識別鍵。預設是隨機值,可用 config: { presence: { key } } 指定(建議用 userId)。多分頁去重的關鍵就在這裡 |
presence_ref | Supabase 自動為「每一次 track」生成的唯一碼。同一 key 下若有多個連線(多分頁),會有多筆、各自的 presence_ref |
| track 的 state | 你呼叫 track(state) 時傳入的自訂資料(userId、username、avatar、typing 等),會原封不動出現在陣列元素裡 |
為什麼 value 是「陣列」而不是單一物件?因為同一個 presence key 可能對應多個連線——這正是多分頁的情況。如果你用 userId 當 key,Alice 開了三個分頁,state["user-alice"] 底下就會有三個元素(三個 presence_ref)。要算「不重複線上人數」,就數 key 的數量(Object.keys(state).length)而不是數陣列總長度。這是 Presence 去重邏輯的基礎,後面實作會反覆用到。
實作範例
下面用可直接執行的 TypeScript,把 Presence 從最基本的上線/下線、到讀取名單渲染頭像、到 typing 打字指示、再到多分頁去重,完整跑一遍。假設 supabase 客戶端已初始化、使用者已登入。
一、最小可用範例:track 上線、監聽三事件
先跑通「上線 → 收到名單 → 別人加入 / 離開」。關鍵:track() 一定要在 subscribe() 成功(SUBSCRIBED)之後才呼叫,否則狀態登記不上。
import type { RealtimeChannel } from '@supabase/supabase-js'
const channel: RealtimeChannel = supabase
.channel('room:demo')
// 1. sync:拿到「完整當前名單」——顯示名單的主邏輯放這裡
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('目前線上完整名單:', state)
})
// 2. join:有人新加入(increment)
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log(`${key} 加入了:`, newPresences)
})
// 3. leave:有人離開或斷線(decrement)
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log(`${key} 離開了:`, leftPresences)
})
.subscribe(async (status) => {
if (status !== 'SUBSCRIBED') return
// 4. 訂閱成功後,才登記自己的狀態(會觸發別人的 join、自己的 sync)
await channel.track({
userId: 'user-123',
username: 'Alice',
online_at: new Date().toISOString(),
})
})
如果你在兩個瀏覽器分頁各跑一次(同一個 channel room:demo),先開的分頁會在後開的分頁 track 時收到 join;後開的分頁一訂閱就會透過 sync 拿到含兩人的完整名單。
二、讀取名單並渲染成線上頭像列
實務上我們要把 presenceState() 那個「key → 陣列」的結構,攤平成一份好渲染的使用者清單。這裡示範一個把狀態轉成「去重後的線上使用者陣列」的函式:
import type { RealtimeChannel } from '@supabase/supabase-js'
interface PresenceUser {
userId: string
username: string
avatar?: string
online_at: string
}
// 把 presenceState() 攤平成「每人一筆」的線上名單(已依 key 去重)
function flattenPresence(channel: RealtimeChannel): PresenceUser[] {
const state = channel.presenceState<PresenceUser>()
// state 形如 { key: [ { presence_ref, ...你 track 的資料 }, ... ] }
return Object.values(state).map((presences) => {
// 同一 key(同一人多分頁)取第一筆即可,達成去重
const [first] = presences
return first
})
}
function setupPresence(roomId: string, me: PresenceUser): RealtimeChannel {
const channel = supabase
// 用 userId 當 presence key:多分頁 / 多裝置自動歸併為一個人
.channel(`room:${roomId}`, {
config: { presence: { key: me.userId } },
})
.on('presence', { event: 'sync' }, () => {
const users = flattenPresence(channel)
renderAvatarBar(users) // 你的 UI:畫出線上頭像列
renderOnlineCount(users.length) // 你的 UI:顯示「N 人在線上」
})
.subscribe(async (status) => {
if (status !== 'SUBSCRIBED') return
await channel.track(me)
})
return channel
}
三個重點:一、渲染名單掛在 sync,因為它給你完整真相,join / leave 只適合做提示。二、flattenPresence 用 Object.values(state) 走訪每個 key,同一 key 只取第一筆達成去重——這搭配「用 userId 當 key」才有意義。三、線上人數用「key 的數量」(即攤平後陣列長度),不是所有 presence_ref 的總數,否則多分頁的人會被重複計算。
三、typing 打字指示:用 track 更新狀態
「某某正在輸入…」可以用 Presence 做成一個乾淨的布林狀態:把 typing 放進 track 的 state,正在打字就 track({ typing: true }),停止就 track({ typing: false })。因為 Presence 有狀態,這比手動用 Broadcast 維護更省事,且該使用者一斷線,typing 狀態自動消失,不會卡住「Alice 正在輸入…」永遠不消失的殘影。
import type { RealtimeChannel } from '@supabase/supabase-js'
interface PresenceUser {
userId: string
username: string
online_at: string
typing?: boolean
}
function setupTypingIndicator(channel: RealtimeChannel, me: PresenceUser) {
// 監聽 sync,把「正在打字」的人挑出來顯示
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<PresenceUser>()
const typingUsers = Object.values(state)
.map(([first]) => first)
.filter((u) => u.typing && u.userId !== me.userId) // 排除自己
renderTypingIndicator(typingUsers) // 你的 UI:顯示「Bob 正在輸入…」
})
// debounce:停止輸入 1.5 秒後才把 typing 設回 false
let stopTimer: ReturnType<typeof setTimeout>
input.addEventListener('input', () => {
// 標記自己正在打字(注意 track 頻率上限,不要每次按鍵都 track)
channel.track({ ...me, typing: true })
clearTimeout(stopTimer)
stopTimer = setTimeout(() => {
channel.track({ ...me, typing: false })
}, 1500)
})
}
這裡有個關鍵取捨要講清楚:Presence 有 track 頻率上限(每個客戶端每 30 秒約 5 次,以官方為準),所以絕對不能每次按鍵都 track。上面的做法是「開始打字時 track(typing:true) 一次、停止 1.5 秒後 track(typing:false) 一次」,把 track 次數壓到很低。如果你的 typing 需要更即時、更高頻(例如每按一個字就更新),那就改用 Broadcast 送 typing 事件(如上一篇的做法),Presence 只維護穩定的線上名單——這是實務上很常見的分工。
四、下線與清理:untrack 與 removeChannel
離開房間或元件卸載時,要主動清理。untrack() 會讓其他人收到 leave;removeChannel() 則連整個訂閱一起釋放(避免連線洩漏)。
// 手動下線(例如使用者點「離開房間」):會觸發別人的 leave
await channel.untrack()
// React:元件卸載時務必清理(同時 untrack + 釋放連線)
useEffect(() => {
const channel = setupPresence(roomId, me)
return () => {
// removeChannel 會取消訂閱並釋放連線;離線會被其他人偵測為 leave
supabase.removeChannel(channel)
}
}, [roomId])
補一個實用細節——背景分頁節流的處理。瀏覽器把分頁切到背景時會節流 WebSocket,可能導致名單過時。回到前景時重新 track 一次更新時間戳,能讓狀態更準:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// 回到前景,重新 track 更新 online_at
channel.track({ ...me, online_at: new Date().toISOString() })
}
})
五、React Hook 封裝:一個可複用的線上名單
把上面的邏輯收進一個 Hook,元件只要拿 onlineUsers 就能渲染:
import { useEffect, useRef, useState } from 'react'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'
interface PresenceUser {
userId: string
username: string
online_at: string
}
export function usePresence(roomId: string, me: PresenceUser) {
const channelRef = useRef<RealtimeChannel | null>(null)
const [onlineUsers, setOnlineUsers] = useState<PresenceUser[]>([])
useEffect(() => {
// 用 userId 當 key,多分頁 / 多裝置去重
const channel = supabase.channel(`room:${roomId}`, {
config: { presence: { key: me.userId } },
})
channelRef.current = channel
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<PresenceUser>()
// 每個 key 取第一筆 => 去重後的線上名單
const users = Object.values(state).map(([first]) => first)
setOnlineUsers(users)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track(me)
}
})
// 卸載時清理(關鍵!避免連線洩漏與幽靈上線)
return () => {
supabase.removeChannel(channel)
}
}, [roomId, me.userId])
return { onlineUsers, onlineCount: onlineUsers.length }
}
這個 Hook 示範了完整的正確模式:用 userId 當 key 去重、名單邏輯掛在 sync、在 SUBSCRIBED 後才 track、卸載時 removeChannel。實際專案可再依需求加上 typing 狀態、頭像 URL 等欄位。
常見錯誤與最佳實踐
坑一:在 subscribe() 成功前就 track(),名單一直空的。
track() 若在訂閱完成(SUBSCRIBED)之前呼叫,狀態不會被正確登記到 channel,其他人也收不到你的 join,你自己的名單也可能是空的。正確做法:一律把 track() 放進 .subscribe((status) => { if (status === 'SUBSCRIBED') { await channel.track(...) } }) 的回呼裡,確保訂閱就緒後才登記。
坑二:只監聽 join/leave,忘了 sync,初次載入名單是空的。
初次訂閱時,Supabase 是透過 sync 事件把「完整的當前名單」送給你,之後才用 join/leave 送增量。只掛 join 的人會發現:自己剛進來時明明有別人在線,卻什麼都沒顯示——因為那些人不是「在你進來後才 join」的。正確做法:把「渲染完整名單」的邏輯掛在 sync(裡面呼叫 presenceState() 重讀全量),join/leave 只用來做「某某加入/離開」的即時提示或動畫。
坑三:沒去重,同一人多分頁/多裝置被算成好幾個人。
預設每個連線拿到的是隨機 presence key,所以同一使用者開三個分頁,presenceState() 就有三個獨立 key,畫面顯示成三個人、人數也灌水。正確做法:用 config: { presence: { key: userId } } 把同一使用者的所有連線歸併到同一個 key;渲染名單時以 key 為單位(每個 key 取第一筆),算人數時數 Object.keys(state).length。這樣多分頁多裝置都只顯示一個人。
坑四:track 太頻繁,撞上頻率上限、狀態更新被丟棄。
Presence 有 track 頻率限制(每個客戶端每 30 秒約 5 次,以官方為準),而且每次 track 都會廣播給所有訂閱者。有人拿 Presence 做每次按鍵、每次滑鼠移動都更新的高頻狀態,結果超過上限、更新被丟棄,還拖慢整個 channel。正確做法:Presence 只放「緩慢變化」的狀態(線上、正在編輯哪份文件、typing 布林值),並用 debounce 壓低 track 次數;真正高頻的事件(游標、逐字打字)改用 Broadcast。狀態用 Presence、事件用 Broadcast,各司其職。
坑五:大量成員硬用 Presence,效能與配額吃緊。
Presence 每次狀態變更都要同步給「所有」訂閱者,成員越多、扇出成本越高,且各方案有 Presence 訊息/秒的上限(Free 較低,以官方為準)、每個 Presence 物件的 key 數也有上限(約 10 個)。在成百上千人的大房間裡,若每個人都頻繁更新狀態,很容易撞牆。正確做法:大規模場景下,減少每人 track 的資料量與頻率、只放必要欄位;考慮把「精確到每個人的狀態」降級為「聚合資訊」(例如只顯示「線上約 1.2k 人」而非逐一列出),把 Presence 留給真正需要看到彼此的中小型協作房間。
坑六:忘了 untrack/removeChannel,殘留幽靈上線。
雖然斷線 Supabase 會自動清理,但元件卸載卻不 removeChannel,會累積 WebSocket 連線直到耗盡配額(和前兩篇同一個坑);而某些切換房間的場景,若不主動 untrack 舊房間,也可能短暫殘留。正確做法:在 React useEffect 的 cleanup(或 Vue onUnmounted)呼叫 supabase.removeChannel(channel),讓上線與下線成對出現。
最佳實踐總結:
track放在SUBSCRIBED之後:訂閱就緒才登記狀態,避免名單空白- 名單邏輯掛
sync:sync給完整真相,join/leave只做增量提示 - 用
userId當 presence key:config: { presence: { key } }讓多分頁多裝置去重 - 算人數數 key、不數 ref:
Object.keys(state).length才是不重複人數 - 狀態用 Presence、高頻事件用 Broadcast:typing 布林用 Presence、逐字/游標用 Broadcast
- debounce 壓低
track頻率:尊重每 30 秒約 5 次的上限,別每次按鍵都 track - 卸載時
removeChannel、必要時untrack:避免連線洩漏與幽靈上線 - 不確定的細節以官方為準:
track頻率上限、Presence 訊息/秒、key 數上限、presenceState()結構都可能調整,正式決策前查官方文件
小結
這是 Supabase 系列教學的第 029 篇。上一篇《Broadcast 廣播》我們用 channel.send({ type: 'broadcast', event, payload }) 與 .on('broadcast', ...),把「稍縱即逝的高頻訊息」做到毫秒級即時、不落資料庫。這一篇補上協作場景最後一塊拼圖——Presence:一份「會自動同步、斷線自動清理」的共享線上名單。
核心觀念濃縮成一句話:Presence 用 channel.track(state) 登記自己的狀態、untrack()(或斷線)自動下線,透過 presenceState() 讀取「key → presence 陣列」結構的完整名單,並監聽 sync(全量真相,渲染名單)/join(有人加入)/leave(有人離開)三個事件;用 config: { presence: { key: userId } } 讓同一人的多分頁多裝置去重,算人數數 key 不數 ref;typing 布林狀態適合放 Presence,逐字高頻則交給 Broadcast;注意 track 頻率上限與大房間的扇出成本。
- 收發模型:
track(state)上線、untrack()下線;presenceState()讀名單 - 三事件:
sync渲染全量、join/leave做增量提示(sync 期間夾帶 join/leave 屬正常對賬) - 去重關鍵:
config: { presence: { key: userId } },算人數數Object.keys(state).length - typing 取捨:布林狀態用 Presence(斷線自動清)、逐字高頻用 Broadcast
- 效能上限:
track每 30 秒約 5 次、Presence 訊息/秒依方案、key 數約 10——大房間要節制
到這裡,Realtime 三大能力你都握齊了:Postgres Changes 管「有沒有寫進 DB」、Broadcast 管「當下的高頻互動」、Presence 管「誰在場」。但這三者要在正式產品裡安全運作,還缺最關鍵的一環——授權。如何用 realtime.messages 的 RLS 政策,確保只有房間成員才能收發 Broadcast、才能看到 Presence 名單,而不是任何人都能偷聽你的私有頻道?下一篇《Realtime Authorization 與實戰》,我們把 private channel、RLS 政策、realtime.topic() 全部串起來,做一個「安全、可上線」的即時協作房間收尾。三大能力齊備,下一步就是把它們鎖好,下一篇見。