# 把即時功能寫成 hooks：我們為 React、Vue 和 Nuxt 做的套件

> 每個團隊把 pusher-js 接進 React 或 Vue 時，都會寫一樣的 useEffect、踩一樣的坑。我們把它整理成三個套件，還讓它們把 AI 的回答即時送到每一個分頁、每一位同事。

Source: https://hibira.io/zh-tw/blog/react-vue-nuxt-realtime
Updated: 2026-11-03

把 pusher-js 接進 React，大概是這樣開始的：

```jsx
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()`：它不管還有誰在用，直接把頻道整個退訂。

```text
sequence: 兩個元件共用 orders 頻道，其中一個卸載時
訂單列表 | 通知鈴鐺 | pusher-js
1 > 3: subscribe('orders')
2 > 3: subscribe('orders') | 拿到同一個頻道
1 > 3: unsubscribe('orders') | 列表卸載
note: 頻道被整個退訂，鈴鐺再也收不到通知
```

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

```text
flow: 用計數決定什麼時候真正退訂
== 掛載
訂單列表|useEvent('orders') --計數 1--> *頻道 orders|訂閱 --計數 2--> 通知鈴鐺|useEvent('orders')
== 卸載
訂單列表|卸載 --計數 1--> *頻道 orders|保持訂閱 --計數 0--> 通知鈴鐺|卸載後才退訂
```

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

## 第二個坑：伺服器渲染和 StrictMode

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

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

```text
flow: 連線只在瀏覽器、只在掛載後建立
伺服器渲染|不連線，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 的回應就斷了，答案只剩一半。

```text
flow: SSE 只回給發問的請求；頻道送給每個訂閱的人
== Server-Sent Events
模型 --串流--> 你的後端 --同一個 HTTP 回應--> 發問的分頁
== 送到頻道
模型 --串流--> 你的後端|streamToChannel() --`trigger()`--> *Hibira|扇出 --WebSocket--> 每個分頁、每位同事
```

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

![兩個瀏覽器同時看著同一個 AI 回答產生：左邊是發問的 Alice，右邊是 Bob](https://hibira.io/portal/articles/demo-shared-answer-zh-TW.webp)

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

### 不是每個 token 一則訊息

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

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

```text
bars: 1,000 個 token、模型每秒產生 50 個時，發布的訊息數（實測）
unit: 則
每個 token 發一則: 1000
*streamToChannel()，每 100 毫秒一則: 203
```

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

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

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

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

```text
sequence: Bob 在答案寫到一半時才加入
你的後端 | *Hibira | Alice | Bob
1 > 2: 第 0～5 則 | 前面的文字
2 > 3: 第 0～5 則
note: Bob 這時才打開頁面並訂閱
1 > 2: 第 6～9 則
2 > 3: 第 6～9 則
2 > 4: 第 6～9 則 | 只看到後半段
1 > 2: 第 10 則：結束 ＋ 完整回答
2 > 3: 結束
2 > 4: 完整回答 | Bob 的畫面補齊
```

如果回答太長，最後一則放不下完整的文字，中途加入的人會看到 `incomplete: true`，你可以在這時打一次 API 拿存好的完整回答。模型出錯或被中斷時，訂閱的人也會收到 `error`，不會一直停在「產生中」。

## 實際寫起來是這樣

### React

```jsx
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 的串流格式它都認得：

```js
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，路由參數變了，訂閱會自動跟著換：

```js
// main.js
createApp(App)
  .use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
  .mount('#app');
```

```vue
<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()` 可以用：

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@hibira-io/nuxt'],
});
```

```ts
// 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 警告。

## 開始使用

```sh
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](https://github.com/hibira-io/react)、[hibira-io/vue](https://github.com/hibira-io/vue)、[hibira-io/nuxt](https://github.com/hibira-io/nuxt)。它們說的是 Pusher 協定，所以連到任何相容的伺服器都可以用；已經在用 Laravel Echo 的專案，把 `echo.connector.pusher` 傳進去，就能和 Echo 共用同一條連線。

免費方案不需要信用卡。如果你的產品正打算加上 AI 助理，或者已經有一堆手寫的 `useEffect` 在處理即時訊息，可以挑一個頁面換成這幾個 hooks 試試看，有任何想法都歡迎在 GitHub 上開 issue 告訴我們。
