第三章:打字機特效實作:深入解析 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 時,具備無人能及的除錯能力!