← 部落格

把即時功能寫成 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():它不管還有誰在用,直接把頻道整個退訂。

訂單列表通知鈴鐺pusher-jssubscribe('orders')subscribe('orders')拿到同一個頻道unsubscribe('orders')列表卸載頻道被整個退訂,鈴鐺再也收不到通知
兩個元件共用 orders 頻道,其中一個卸載時

我們的解法是幫每個頻道記一個計數:每個用到它的元件加一,卸載時減一,歸零時才真正退訂。你只要寫 useChannel('orders') 或 useEvent('orders', 'order.created', handler),誰先誰後、誰先卸載都不用管。

掛載

訂單列表useEvent('orders')
計數 1
頻道 orders訂閱
計數 2
通知鈴鐺useEvent('orders')

卸載

訂單列表卸載
計數 1
頻道 orders保持訂閱
計數 0
通知鈴鐺卸載後才退訂
用計數決定什麼時候真正退訂

useEvent 也會記住你最新的處理函式。元件每次重新渲染都會產生新的函式,如果每次都重新綁定事件,忙碌的頻道上會出現短暫的空窗;我們只綁一次,事件來的時候才呼叫最新的那一個。

第二個坑:伺服器渲染和 StrictMode

Next.js、Remix、Nuxt 都會先在伺服器上渲染一次頁面。如果在元件的本體或 useMemo 裡建立連線,伺服器上也會跟著連線:多一條沒人用的 WebSocket,還多算一條連線數。

React 18 之後的 StrictMode 會在開發模式下把元件掛載、卸載、再掛載一次,用來找出沒有清乾淨的副作用。手寫的連線程式碼常常在這裡留下第二條連線。

伺服器渲染不連線,hooks 回傳空值
HTML
瀏覽器 hydration畫面和伺服器一致
掛載後
建立連線ws.hibira.io
連線只在瀏覽器、只在掛載後建立

HibiraProvider 只在 useEffect 裡建立連線,卸載時中斷,StrictMode 的第二次掛載會拿到一條乾淨的新連線。連線建立之前,usePresence 回傳空的成員列表、useConnectionState 回傳 initialized,和伺服器渲染出來的結果一樣,所以 hydration 不會出現不一致的警告。Vue 版本也是一樣的做法:外掛在瀏覽器裡第一次被用到時才連線,伺服器上什麼都不做。

第三件事:AI 的回答,不只給發問的那個分頁

這兩年很多產品都加上了 AI 助理。最常見的做法是用 Server-Sent Events:瀏覽器發一個請求,後端把模型產生的文字一段一段寫回同一個回應。

這在一個人、一個分頁的時候很好用。但產品一旦變成多人協作,問題就來了:Alice 在共享的規格文件裡問了 AI 一個問題,坐在旁邊的 Bob 看不到答案正在產生,要等 Alice 那邊寫完、存檔、Bob 重新整理之後才看得到。Alice 自己開了兩個分頁,另一個分頁也看不到。手機網路斷了一下,SSE 的回應就斷了,答案只剩一半。

Server-Sent Events

模型
串流
你的後端
同一個 HTTP 回應
發問的分頁

送到頻道

模型
串流
你的後端streamToChannel()
trigger()
Hibira扇出
WebSocket
每個分頁、每位同事
SSE 只回給發問的請求;頻道送給每個訂閱的人

所以我們在套件裡加了一組工具:後端的 streamToChannel() 和前端的 useStream()。後端把模型的串流交給 streamToChannel(),它會把文字發布到一個頻道;前端任何訂閱了這個頻道的元件,都用 useStream() 拿到逐漸長出來的文字。

兩個瀏覽器同時看著同一個 AI 回答產生:左邊是發問的 Alice,右邊是 Bob
兩個瀏覽器同時看著同一個 AI 回答產生:左邊是發問的 Alice,右邊是 Bob

上圖是我們在本機測試時截的:Alice 按下「問 AI 助理」,答案同時出現在 Alice 和 Bob 的畫面上,Bob 的畫面不需要做任何事。

不是每個 token 一則訊息

模型產生文字的速度大約是每秒幾十個 token。如果每個 token 都發布一則訊息,一個長一點的回答就是上千則,訊息額度很快就用完,接收端的瀏覽器也要處理上千次重新渲染。

streamToChannel() 會把 token 先收集起來,每 100 毫秒發布一次。不管模型多快,一個頻道每秒大約只會收到十則訊息,畫面看起來仍然是連續流出來的。

每個 token 發一則1,000 則streamToChannel(),每 100 毫秒一則203 則
1,000 個 token、模型每秒產生 50 個時,發布的訊息數(實測)

模型越快,省得越多:同樣 1,000 個 token,模型每秒產生 100 個時只要 103 則。每一則訊息也會控制在 3,500 bytes 以內,低於免費方案每則 4 KB 的上限;切開長文字的時候不會把一個中文字或 emoji 切成兩半。

順序、斷線和中途加入的人

每一則訊息都帶著一個序號,useStream() 依照序號把文字接起來,就算順序亂了也會等缺的那一則到了再接。

最後一則訊息除了「結束了」的標記,還會附上完整的回答(只要放得進一則訊息)。這是為了中途才加入的人:Bob 在答案寫到一半時才打開頁面,他看不到前面的文字,但最後一則一到,他的畫面就會換成完整的答案。網路斷了一下的人也是一樣。

你的後端HibiraAliceBob第 0~5 則前面的文字第 0~5 則Bob 這時才打開頁面並訂閱第 6~9 則第 6~9 則第 6~9 則只看到後半段第 10 則:結束 + 完整回答結束完整回答Bob 的畫面補齊
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 告訴我們。