把即時功能寫成 hooks:我們為 React、Vue 和 Nuxt 做的套件
· Hibira
每個團隊把 pusher-js 接進 React 或 Vue 時,都會寫一樣的 useEffect、踩一樣的坑。我們把它整理成三個套件,還讓它們把 AI 的回答即時送到每一個分頁、每一位同事。

把 pusher-js 接進 React,大概是這樣開始的:
useEffect(() => {
const channel = pusher.subscribe('orders');
channel.bind('order.created', (order) => setOrders((list) => [order, ...list]));
return () => pusher.unsubscribe('orders');
}, []);六行,看起來沒什麼問題。然後專案長大了:訂單列表和右上角的通知鈴鐺都要聽 orders;切換頁面時鈴鐺還在,列表卸載了,鈴鐺就再也收不到通知。開發模式下 React 的 StrictMode 會把元件掛載兩次,連線也跟著開了兩條。換成 Next.js 之後,伺服器渲染時 new Pusher() 也跑了一次,在伺服器上連了一條沒人用的 WebSocket。
這些坑我們在很多專案的程式碼裡都看過,寫法幾乎一模一樣,踩的坑也一模一樣。所以我們把它們整理成三個套件:@hibira-io/react、@hibira-io/vue 和 @hibira-io/nuxt。這篇文章說說它們解決了什麼問題、怎麼解決的,以及順便做的另一件事:把 AI 的回答即時送到每一個分頁、每一位同事。
第一個坑:兩個元件,一個頻道
pusher-js 的 subscribe() 對同一個頻道名稱只會訂閱一次,兩個元件拿到的是同一個頻道物件。問題在 unsubscribe():它不管還有誰在用,直接把頻道整個退訂。
我們的解法是幫每個頻道記一個計數:每個用到它的元件加一,卸載時減一,歸零時才真正退訂。你只要寫 useChannel('orders') 或 useEvent('orders', 'order.created', handler),誰先誰後、誰先卸載都不用管。
掛載
卸載
useEvent 也會記住你最新的處理函式。元件每次重新渲染都會產生新的函式,如果每次都重新綁定事件,忙碌的頻道上會出現短暫的空窗;我們只綁一次,事件來的時候才呼叫最新的那一個。
第二個坑:伺服器渲染和 StrictMode
Next.js、Remix、Nuxt 都會先在伺服器上渲染一次頁面。如果在元件的本體或 useMemo 裡建立連線,伺服器上也會跟著連線:多一條沒人用的 WebSocket,還多算一條連線數。
React 18 之後的 StrictMode 會在開發模式下把元件掛載、卸載、再掛載一次,用來找出沒有清乾淨的副作用。手寫的連線程式碼常常在這裡留下第二條連線。
HibiraProvider 只在 useEffect 裡建立連線,卸載時中斷,StrictMode 的第二次掛載會拿到一條乾淨的新連線。連線建立之前,usePresence 回傳空的成員列表、useConnectionState 回傳 initialized,和伺服器渲染出來的結果一樣,所以 hydration 不會出現不一致的警告。Vue 版本也是一樣的做法:外掛在瀏覽器裡第一次被用到時才連線,伺服器上什麼都不做。
第三件事:AI 的回答,不只給發問的那個分頁
這兩年很多產品都加上了 AI 助理。最常見的做法是用 Server-Sent Events:瀏覽器發一個請求,後端把模型產生的文字一段一段寫回同一個回應。
這在一個人、一個分頁的時候很好用。但產品一旦變成多人協作,問題就來了:Alice 在共享的規格文件裡問了 AI 一個問題,坐在旁邊的 Bob 看不到答案正在產生,要等 Alice 那邊寫完、存檔、Bob 重新整理之後才看得到。Alice 自己開了兩個分頁,另一個分頁也看不到。手機網路斷了一下,SSE 的回應就斷了,答案只剩一半。
Server-Sent Events
送到頻道
trigger()所以我們在套件裡加了一組工具:後端的 streamToChannel() 和前端的 useStream()。後端把模型的串流交給 streamToChannel(),它會把文字發布到一個頻道;前端任何訂閱了這個頻道的元件,都用 useStream() 拿到逐漸長出來的文字。

上圖是我們在本機測試時截的:Alice 按下「問 AI 助理」,答案同時出現在 Alice 和 Bob 的畫面上,Bob 的畫面不需要做任何事。
不是每個 token 一則訊息
模型產生文字的速度大約是每秒幾十個 token。如果每個 token 都發布一則訊息,一個長一點的回答就是上千則,訊息額度很快就用完,接收端的瀏覽器也要處理上千次重新渲染。
streamToChannel() 會把 token 先收集起來,每 100 毫秒發布一次。不管模型多快,一個頻道每秒大約只會收到十則訊息,畫面看起來仍然是連續流出來的。
模型越快,省得越多:同樣 1,000 個 token,模型每秒產生 100 個時只要 103 則。每一則訊息也會控制在 3,500 bytes 以內,低於免費方案每則 4 KB 的上限;切開長文字的時候不會把一個中文字或 emoji 切成兩半。
順序、斷線和中途加入的人
每一則訊息都帶著一個序號,useStream() 依照序號把文字接起來,就算順序亂了也會等缺的那一則到了再接。
最後一則訊息除了「結束了」的標記,還會附上完整的回答(只要放得進一則訊息)。這是為了中途才加入的人:Bob 在答案寫到一半時才打開頁面,他看不到前面的文字,但最後一則一到,他的畫面就會換成完整的答案。網路斷了一下的人也是一樣。
如果回答太長,最後一則放不下完整的文字,中途加入的人會看到 incomplete: true,你可以在這時打一次 API 拿存好的完整回答。模型出錯或被中斷時,訂閱的人也會收到 error,不會一直停在「產生中」。
實際寫起來是這樣
React
import { HibiraProvider, useEvent, usePresence, useStream } from '@hibira-io/react';
export function App() {
return (
<HibiraProvider appKey="你的 app key" options={{ channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } }}>
<Room id="spec-42" />
</HibiraProvider>
);
}
function Room({ id }) {
const { members } = usePresence(`presence-room-${id}`);
const { text, done } = useStream(`private-room-${id}-ai`);
useEvent(`private-room-${id}`, 'comment.created', (comment) => addComment(comment));
return (
<>
<Avatars members={members} />
<Answer text={text} loading={!done} />
</>
);
}後端(Node.js,任何框架都可以)把模型的串流原封不動交給 streamToChannel()。Anthropic、OpenAI、Gemini 和 Vercel AI SDK 的串流格式它都認得:
import Pusher from 'pusher';
import { streamToChannel } from '@hibira-io/react/server';
const pusher = new Pusher({ appId, key, secret, host: 'ws.hibira.io', useTLS: true });
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages });
await streamToChannel(pusher, `private-room-${id}-ai`, stream, { meta: { asker: user.name } });Vue
Vue 3 和 Vue 2.7 都能用,頻道名稱可以是 ref 或 getter,路由參數變了,訂閱會自動跟著換:
// main.js
createApp(App)
.use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
.mount('#app');<script setup>
import { usePresence, useStream } from '@hibira-io/vue';
const props = defineProps({ id: String });
const { members, count } = usePresence(() => `presence-room-${props.id}`);
const { text, done } = useStream(() => `private-room-${props.id}-ai`);
</script>Nuxt
Nuxt 3 和 Nuxt 4 只要加一行模組設定,composables 會自動匯入,伺服器路由裡也直接有 useHibiraServer() 和 streamToChannel() 可以用:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@hibira-io/nuxt'],
});// server/api/ask.post.ts
export default defineEventHandler(async (event) => {
const { roomId, question } = await readBody(event);
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
event.waitUntil(streamToChannel(useHibiraServer(event), `private-room-${roomId}-ai`, stream));
return { ok: true };
});還在用 Nuxt 2 的專案也沒有被忘記:Nuxt 2.16 之後跑在 Vue 2.7 上,改用 @hibira-io/nuxt/nuxt2 這個模組就可以。
我們怎麼測試
這三個套件都很小,沒有額外的相依套件,除了 pusher-js 之外什麼都不用裝。但它們會跑在很多種環境裡,所以測試範圍比程式碼本身大得多:
- React 17、18、19,Vue 3.5 和 Vue 2.7,各自跑一遍單元測試。
- 端對端測試:兩個用戶端連到真正的伺服器,驗證 presence 加入和離開、用戶端事件,以及 200 個 token 的串流在兩邊都完整拼回來。
- Nuxt 4.6、3.21 和 2.18 各建一個小專案,在 Chromium 裡打開兩個使用者,確認連線、presence、伺服器事件和串流文字都正常,主控台沒有錯誤、也沒有 hydration 警告。
開始使用
npm install @hibira-io/react pusher-js # React
npm install @hibira-io/vue pusher-js # Vue 3 / Vue 2.7
npx nuxi module add @hibira-io/nuxt # Nuxt 3 / 4開始之前,有幾件事要先在 Hibira 主控台設定好:
- 建立應用程式,記下憑證。 App ID 和 key 在應用程式的「概覽」頁面隨時都看得到;secret 只會在建立時顯示一次,請當下就存進後端的環境變數(Nuxt 用
NUXT_PUBLIC_HIBIRA_KEY、NUXT_HIBIRA_APP_ID、NUXT_HIBIRA_SECRET)。key 是公開的,secret 只能放在伺服器上。 - 加上允許的來源。 免費方案不能用
*,要把你的網站網址(開發時例如http://localhost:3000)加進「允許的來源」,否則瀏覽器會連不上。 - 準備授權路由。 私人和 presence 頻道需要後端的
/api/hibira/auth確認使用者身分並簽署訂閱,三個 README 裡都有可以直接複製的範例。 - 每則訊息 4 KB。 免費方案每則事件最多 4 KB;
streamToChannel()已經自動把訊息控制在這個範圍內,自己發布的事件也要注意大小。
想親手重現上面 Alice 和 Bob 的畫面,請用兩個不同的瀏覽器,或一個一般視窗加一個無痕視窗,各自登入不同帳號。同一個瀏覽器開兩個分頁通常是同一位使用者,presence 只會算一個人。
三個套件都是 MIT 授權,原始碼在 GitHub 上:hibira-io/react、hibira-io/vue、hibira-io/nuxt。它們說的是 Pusher 協定,所以連到任何相容的伺服器都可以用;已經在用 Laravel Echo 的專案,把 echo.connector.pusher 傳進去,就能和 Echo 共用同一條連線。
免費方案不需要信用卡。如果你的產品正打算加上 AI 助理,或者已經有一堆手寫的 useEffect 在處理即時訊息,可以挑一個頁面換成這幾個 hooks 試試看,有任何想法都歡迎在 GitHub 上開 issue 告訴我們。