เรียลไทม์ในรูปแบบ hooks: แพ็กเกจของเราสำหรับ React, Vue และ Nuxt
· Hibira
ทุกทีมเขียน useEffect แบบเดียวกันเพื่อต่อ pusher-js เข้ากับ React หรือ Vue และเจอบั๊กแบบเดียวกัน เราจึงรวมเป็นสามแพ็กเกจ และให้มันสตรีมคำตอบของ AI ไปถึงทุกแท็บและทุกคนในทีม

การต่อ pusher-js เข้ากับ React มักเริ่มต้นแบบนี้:
useEffect(() => {
const channel = pusher.subscribe('orders');
channel.bind('order.created', (order) => setOrders((list) => [order, ...list]));
return () => pusher.unsubscribe('orders');
}, []);หกบรรทัด ดูไม่มีปัญหาอะไร แต่พอโปรเจกต์โตขึ้น รายการคำสั่งซื้อและกระดิ่งแจ้งเตือนมุมขวาบนต่างก็ฟัง orders เหมือนกัน เมื่อเปลี่ยนหน้า รายการถูก unmount ไป กระดิ่งยังอยู่ แต่หลังจากนั้นกระดิ่งก็ไม่ได้รับแจ้งเตือนอีกเลย ในโหมดพัฒนา StrictMode ของ React จะ mount คอมโพเนนต์สองครั้ง การเชื่อมต่อก็เปิดสองเส้นตามไปด้วย พอย้ายไป Next.js ตัว new Pusher() ก็รันตอนเรนเดอร์ฝั่งเซิร์ฟเวอร์ด้วย เปิด WebSocket บนเซิร์ฟเวอร์ทิ้งไว้โดยไม่มีใครใช้
เราเห็นโค้ดแบบนี้ในหลายโปรเจกต์ เขียนแทบจะเหมือนกันทุกครั้ง และเจอกับดักเดียวกันทุกครั้ง เราจึงรวบรวมมันเป็นสามแพ็กเกจ: @hibira-io/react, @hibira-io/vue และ @hibira-io/nuxt บทความนี้เล่าว่าแพ็กเกจเหล่านี้แก้ปัญหาอะไร แก้อย่างไร และอีกสิ่งหนึ่งที่เราทำเพิ่มไประหว่างทาง คือการส่งคำตอบของ AI ไปถึงทุกแท็บและเพื่อนร่วมทีมทุกคนขณะที่มันกำลังถูกเขียน
กับดักที่หนึ่ง: สองคอมโพเนนต์ หนึ่งแชนเนล
subscribe() ของ pusher-js จะ subscribe ชื่อแชนเนลเดียวกันเพียงครั้งเดียว สองคอมโพเนนต์จึงได้ออบเจกต์แชนเนลตัวเดียวกัน ปัญหาอยู่ที่ unsubscribe() มันไม่สนว่ายังมีใครใช้อยู่ แต่ยกเลิกทั้งแชนเนลทันที
วิธีแก้ของเราคือนับจำนวนต่อแชนเนล ทุกคอมโพเนนต์ที่ใช้มันบวกหนึ่ง ทุกครั้งที่ unmount ลบหนึ่ง และจะยกเลิกจริงก็ต่อเมื่อนับถึงศูนย์ คุณแค่เขียน useChannel('orders') หรือ useEvent('orders', 'order.created', handler) แล้วไม่ต้องสนใจว่าใคร mount ก่อนหรือใครจากไปก่อน
Mount
Unmount
useEvent ยังจำ handler ล่าสุดของคุณไว้ด้วย คอมโพเนนต์สร้างฟังก์ชันใหม่ทุกครั้งที่เรนเดอร์ ถ้า bind อีเวนต์ใหม่ทุกครั้ง แชนเนลที่มีข้อความเข้าถี่ ๆ จะมีช่องว่างสั้น ๆ ที่พลาดอีเวนต์ได้ เรา bind ครั้งเดียว แล้วเรียก handler ตัวล่าสุดเมื่ออีเวนต์มาถึง
กับดักที่สอง: การเรนเดอร์ฝั่งเซิร์ฟเวอร์และ StrictMode
Next.js, Remix และ Nuxt เรนเดอร์หน้าบนเซิร์ฟเวอร์ก่อน ถ้าคุณสร้างการเชื่อมต่อในตัวคอมโพเนนต์หรือใน useMemo เซิร์ฟเวอร์ก็จะเชื่อมต่อด้วย ได้ WebSocket เพิ่มมาหนึ่งเส้นที่ไม่มีใครใช้ และถูกนับจำนวนการเชื่อมต่อเพิ่มอีกหนึ่ง
ตั้งแต่ React 18 StrictMode จะ mount, unmount และ mount คอมโพเนนต์อีกครั้งในโหมดพัฒนา เพื่อหา side effect ที่ไม่ได้เก็บกวาด โค้ดเชื่อมต่อที่เขียนเองมักทิ้งการเชื่อมต่อเส้นที่สองไว้ตรงนี้
HibiraProvider เชื่อมต่อเฉพาะใน useEffect และตัดการเชื่อมต่อเมื่อ unmount การ mount ครั้งที่สองของ 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() ซึ่งจะเผยแพร่ข้อความเข้าแชนเนล คอมโพเนนต์ไหนก็ตามที่ subscribe แชนเนลนั้นจะได้ข้อความที่ค่อย ๆ ยาวขึ้นผ่าน useStream()

ภาพนี้เราจับไว้ระหว่างทดสอบบนเครื่อง: Alice กด "ถาม AI" แล้วคำตอบก็ปรากฏบนหน้าจอของ Alice และ Bob พร้อมกัน ฝั่ง Bob ไม่ต้องทำอะไรเลย
ไม่ใช่หนึ่งข้อความต่อหนึ่งโทเค็น
โมเดลเขียนได้หลายสิบโทเค็นต่อวินาที ถ้าเผยแพร่หนึ่งข้อความต่อโทเค็น คำตอบยาวหน่อยก็กลายเป็นพันข้อความ โควตาข้อความหมดเร็ว และเบราว์เซอร์ฝั่งรับก็ต้องเรนเดอร์ใหม่เป็นพันครั้ง
streamToChannel() จะเก็บโทเค็นไว้ก่อนแล้วเผยแพร่ทุก 100 มิลลิวินาที ไม่ว่าโมเดลจะเร็วแค่ไหน แชนเนลจะได้รับประมาณสิบข้อความต่อวินาที และข้อความบนหน้าจอก็ยังดูไหลต่อเนื่อง
ยิ่งโมเดลเร็ว ยิ่งประหยัดมาก: 1,000 โทเค็นเท่าเดิมที่ 100 โทเค็นต่อวินาทีใช้เพียง 103 ข้อความ แต่ละข้อความยังมีขนาดไม่เกิน 3,500 ไบต์ ต่ำกว่าขีดจำกัด 4 KB ต่อข้อความของแผน Free และเมื่อแบ่งข้อความยาว ๆ ก็จะไม่ตัดกลางตัวอักษรไทยหรืออีโมจิ
ลำดับ การหลุดการเชื่อมต่อ และคนที่เข้ามากลางทาง
ทุกข้อความมีหมายเลขลำดับ useStream() ต่อข้อความตามลำดับนั้น ถ้ามาสลับลำดับก็จะรอข้อความที่ขาดก่อนแล้วค่อยต่อ
ข้อความสุดท้ายมีเครื่องหมาย "เสร็จแล้ว" และแนบคำตอบฉบับเต็มไว้ด้วยถ้าใส่ในข้อความเดียวได้ นี่ทำไว้สำหรับคนที่เข้ามากลางทาง Bob เปิดหน้าตอนคำตอบเขียนไปได้ครึ่งหนึ่งแล้ว เขาไม่เห็นข้อความช่วงแรก แต่ทันทีที่ข้อความสุดท้ายมาถึง หน้าจอของเขาจะเปลี่ยนเป็นคำตอบฉบับสมบูรณ์ คนที่การเชื่อมต่อหลุดไปชั่วครู่ก็เช่นกัน
ถ้าคำตอบยาวเกินกว่าข้อความสุดท้ายจะใส่ได้ทั้งหมด คนที่เข้ามากลางทางจะเห็น incomplete: true ซึ่งคุณดึงคำตอบที่บันทึกไว้จาก API ของคุณได้หนึ่งครั้ง เมื่อโมเดลผิดพลาดหรือถูกหยุด ผู้ subscribe จะได้รับ error ด้วย ไม่ค้างอยู่ที่ "กำลังสร้าง" ตลอดไป
เขียนเป็นโค้ดได้แบบนี้
React
import { HibiraProvider, useEvent, usePresence, useStream } from '@hibira-io/react';
export function App() {
return (
<HibiraProvider appKey="your 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 ได้ เมื่อพารามิเตอร์ของ route เปลี่ยน การ subscribe ก็เปลี่ยนตามเอง:
// 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 จะถูก import อัตโนมัติ และใน server route ก็ใช้ 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 ได้
เราทดสอบอย่างไร
ทั้งสามแพ็กเกจมีขนาดเล็กและไม่มี dependency อื่นนอกจาก pusher-js แต่มันต้องทำงานในหลายสภาพแวดล้อม การทดสอบจึงครอบคลุมกว้างกว่าตัวโค้ดมาก:
- รัน unit test บน React 17, 18, 19 และ Vue 3.5, Vue 2.7 ทีละเวอร์ชัน
- End-to-end test: ไคลเอนต์สองตัวเชื่อมต่อกับเซิร์ฟเวอร์จริง ตรวจการเข้าและออกของ presence, client event และสตรีม 200 โทเค็นที่ต้องประกอบกลับครบทั้งสองฝั่ง
- สร้างแอปเล็ก ๆ บน Nuxt 4.6, 3.21 และ 2.18 อย่างละตัว เปิดใน Chromium เป็นผู้ใช้สองคน ตรวจการเชื่อมต่อ, presence, อีเวนต์จากเซิร์ฟเวอร์ และข้อความที่สตรีมมา โดยไม่มี error ในคอนโซลและไม่มีคำเตือน 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 ดูได้ตลอดในหน้า Overview ของแอป ส่วน secret จะแสดงเพียงครั้งเดียวตอนสร้างแอป ให้เก็บลงตัวแปรสภาพแวดล้อมของแบ็กเอนด์ทันที (สำหรับ Nuxt คือ
NUXT_PUBLIC_HIBIRA_KEY,NUXT_HIBIRA_APP_ID,NUXT_HIBIRA_SECRET) key เปิดเผยได้ แต่ secret ต้องอยู่บนเซิร์ฟเวอร์เท่านั้น - เพิ่ม allowed origins แผน Free ใช้
*ไม่ได้ ต้องเพิ่มที่อยู่เว็บไซต์ของคุณ (ตอนพัฒนา เช่นhttp://localhost:3000) ใน Allowed origins ไม่อย่างนั้นเบราว์เซอร์จะเชื่อมต่อไม่ได้ - เตรียม route สำหรับการอนุญาต แชนเนลแบบ private และ presence ต้องมี
/api/hibira/authที่แบ็กเอนด์ คอยตรวจว่าใครล็อกอินอยู่และเซ็นการ subscribe ให้ ใน README ทั้งสามมีตัวอย่างที่คัดลอกไปใช้ได้เลย - 4 KB ต่อข้อความ บนแผน Free แต่ละอีเวนต์มีขนาดได้ไม่เกิน 4 KB
streamToChannel()คุมขนาดให้อยู่แล้ว แต่อีเวนต์ที่คุณเผยแพร่เองต้องระวังขนาดด้วย
ถ้าอยากลองทำหน้าจอของ Alice และ Bob ด้วยตัวเอง ให้ใช้เบราว์เซอร์สองตัว หรือหน้าต่างปกติกับหน้าต่างส่วนตัว (incognito) และล็อกอินคนละผู้ใช้ สองแท็บในเบราว์เซอร์เดียวกันมักเป็นผู้ใช้คนเดียวกัน และ presence จะนับเป็นคนเดียว
ทั้งสามแพ็กเกจใช้สัญญาอนุญาต MIT และมีซอร์สโค้ดบน GitHub: hibira-io/react, hibira-io/vue, hibira-io/nuxt มันพูดโปรโตคอล Pusher จึงใช้กับเซิร์ฟเวอร์ที่เข้ากันได้ตัวไหนก็ได้ ถ้าโปรเจกต์ของคุณใช้ Laravel Echo อยู่แล้ว ส่ง echo.connector.pusher เข้าไปก็ใช้การเชื่อมต่อเดียวกับ Echo ได้
แผน Free ไม่ต้องใช้บัตรเครดิต ถ้าคุณกำลังจะเพิ่มผู้ช่วย AI ให้ผลิตภัณฑ์ หรือมี useEffect ที่เขียนเองจัดการข้อความเรียลไทม์อยู่เต็มไปหมด ลองเปลี่ยนสักหน้าหนึ่งมาใช้ hooks เหล่านี้ดู ถ้ามีไอเดียอะไร เปิด issue บน GitHub มาบอกเราได้เลย