第三章:打字機特效實作:深入解析 AI Streaming API 的前端處理
如果你想開發一款具備 AI 功能的商業級 SaaS,一個會「一個字一個字慢慢吐出來」的打字機特效 (Streaming UI) 是絕對必備的。如果讓使用者對著白畫面發呆 10 秒才一口氣看到完整回答,他們只會覺得你的網站又卡又慢,以為伺服器當機了。
本章將帶你手把手解析,如何在前端接收並渲染由 OpenAI 或 Anthropic API 傳回來的 Streaming 資料串流。
什麼是 ReadableStream?
在傳統的 API 請求中,我們習慣用 `await fetch(...)` 拿到回應後,直接 `await res.json()` 把整包資料轉成 JavaScript 的 Object 物件。 但在 Streaming 模式下,這招行不通。伺服器不會一次給你整包 JSON,它會像擠牙膏一樣,一小塊一小塊 (Chunk) 地把文字塞給你。
在現代瀏覽器的 JavaScript 中,我們處理這種資料的介面叫做 `ReadableStream`。
實戰:打造前端打字機 Hook
為了讓邏輯更乾淨,我們不應該把這些複雜的解碼邏輯塞在 UI 裡面。我們來寫一個名為 `useAiChat` 的 Custom Hook。 這個 Hook 的任務是:發送使用者的訊息給後端 API,然後負責接收源源不絕的文字流,並把文字串接起來。
```typescript import { useState } from "react"
export function useAiChat() { const [currentMessage, setCurrentMessage] = useState("") const [isLoading, setIsLoading] = useState(false)
const sendMessage = async (userText: string) => { setIsLoading(true) setCurrentMessage("") // 清空目前的訊息準備接收新回覆
try {
// 呼叫我們自己寫的 Next.js Route Handler (後端)
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userText })
})
if (!response.ok) throw new Error("API 請求失敗")
if (!response.body) throw new Error("無效的資料串流")
// 取得 ReadableStream 的閱讀器
const reader = response.body.getReader()
// 將傳過來的位元組 (Bytes) 解碼成人類看得懂的文字 (UTF-8)
const decoder = new TextDecoder("utf-8")
let done = false
// 核心迴圈:只要還沒結束,就一直讀取新的文字區塊
while (!done) {
const { value, done: readerDone } = await reader.read()
done = readerDone
if (value) {
// stream: true 告訴 decoder,這個位元組可能是不完整的,先不要報錯,等下一包湊齊再解碼
const chunkString = decoder.decode(value, { stream: true })
// 把新進來的文字片段,拼接到目前的字串尾端
setCurrentMessage((prev) => prev + chunkString)
}
}
} catch (error) {
console.error("Streaming 發生錯誤:", error)
setCurrentMessage("抱歉,連線發生錯誤。")
} finally {
setIsLoading(false)
}
}
return { currentMessage, sendMessage, isLoading } } ```
這段看似簡單的 while 迴圈,其實解決了非常深奧的前端問題。它讓我們的網頁能像水管一樣,源源不絕地接收字元。
在 UI 中使用這個 Hook
有了上述強大的 Hook 幫我們扛下複雜的位元組解碼邏輯後,我們在 UI 元件裡的寫法就會變得超級簡單乾淨:
```tsx "use client" import { useState } from "react" import { useAiChat } from "./useAiChat"
export default function AiChatBox() { const [input, setInput] = useState("") const { currentMessage, sendMessage, isLoading } = useAiChat()
const handleSubmit = () => { if (!input.trim()) return sendMessage(input) setInput("") // 清空輸入框 }
return (
{/* 這裡是 AI 回覆區 */}
<div className="min-h-[150px] mb-4 p-4 bg-gray-50 rounded-lg text-gray-800 whitespace-pre-wrap leading-relaxed shadow-inner">
{currentMessage || (isLoading ? "思考中..." : "請在下方輸入問題開始對話。")}
{/* 小彩蛋:加上一個閃爍的游標,讓打字機感覺更逼真 */}
{isLoading && <span className="inline-block w-2 h-4 ml-1 bg-blue-500 animate-pulse" />}
</div>
{/* 這裡是輸入區 */}
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
className="flex-1 p-3 border rounded-xl focus:outline-blue-500 focus:ring-2 focus:ring-blue-200 transition-all"
placeholder="問我任何事..."
/>
<button
onClick={handleSubmit}
disabled={isLoading}
className="px-6 py-3 bg-blue-600 text-white font-bold rounded-xl hover:bg-blue-700 disabled:opacity-50 transition-all"
>
送出
</button>
</div>
</div>
) } ```
為什麼要用 `whitespace-pre-wrap`?
在渲染 AI 回應的 `
AI 回傳的文章常常帶有換行符號 (`\n`)。在 HTML 的預設行為中,純文字裡面的 `\n` 會被視為一個普通的空白,導致排版全部擠成一團。 加上 `whitespace-pre-wrap` 可以強制瀏覽器「尊重文字字串裡面的換行符號」,完美還原 AI 生成的段落排版與程式碼縮排。
AI 生態系的超級作弊碼:Vercel AI SDK
如果你覺得自己刻 `ReadableStream` 和 `while` 迴圈還是太麻煩了,業界還有一個大絕招。 Next.js 的母公司 Vercel 推出了一套專門處理 AI 對話的開源庫:Vercel AI SDK (`npm i ai`)。
它把我們上面寫的 `useAiChat` 封裝成了極致精簡的 `useChat` Hook,不但自帶 Streaming,連你的發問歷史紀錄、重新生成、停止生成功能都一併做好了。在真實的商業專案中,我們絕大部分都會直接依賴這個強大的 SDK 來加速開發。 但了解底層的 ReadableStream 原理,會讓你在遇到千奇百怪的 Bug 時,具備無人能及的除錯能力!
🔧 打字機特效的深入實作
控制打字速度
function TypeWriter({ text, speed = 50 }: { text: string; speed?: number }) {
const [displayed, setDisplayed] = useState('')
const index = useRef(0)
useEffect(() => {
index.current = 0
setDisplayed('')
const timer = setInterval(() => {
if (index.current < text.length) {
setDisplayed(prev => prev + text[index.current])
index.current++
} else {
clearInterval(timer)
}
}, speed)
return () => clearInterval(timer)
}, [text, speed])
return <span>{displayed}<Cursor blink /></span>
}
進階功能
| 功能 | 實作方式 | |------|---------| | 暫停/繼續 | 用 useState 控制 setInterval 的執行 | | 跳過動畫 | 直接顯示完整文字 | | 不同速度 | 根據內容類型調整 speed(程式碼慢、對話快) | | Markdown 渲染 | 逐字顯示時即時解析 Markdown | | 中斷處理 | 新訊息到來時重置動畫 |
即時聊天 UI 設計
聊天 UI 的核心元件
| 元件 | 功能 | 實作考量 | |:----|:----|:--------| | 訊息列表 | 顯示對話歷史 | 虛擬滾動(大量訊息時) | | 輸入框 | 輸入訊息 | 支援 Enter 送出、Shift+Enter 換行 | | 傳送按鈕 | 發送訊息 | 空內容時禁用 | | 打字指示器 | 顯示 AI 正在輸入 | WebSocket 狀態通知 |
React 即時更新
const [messages, setMessages] = useState<Message[]>([]);
const ws = useRef<WebSocket>(null);
useEffect(() => {
ws.current = new WebSocket('ws://localhost:8000/ws/chat');
ws.current.onmessage = (event) => {
// 收到 AI 回覆,即時更新 UI
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// 追加到最後一則 AI 訊息
return [...prev.slice(0, -1), {
...last, content: last.content + event.data
}];
}
return [...prev, { role: 'assistant', content: event.data }];
});
};
}, []);
下一章預告:OpenAI Streaming
UI 準備好之後,下一章教你串接 OpenAI 的 Streaming API——讓 AI 的回應即時顯示在聊天室中。
解鎖完整教學內容
本章為付費內容。加入專案即可解鎖超過 5000 字的深度解析,包含 10 個以上神級 Prompt 與真實 Source Code 範例!